Compare commits

..

4 Commits

Author SHA1 Message Date
celestial-vault a07554f191 rework the docker:shell script to reuse an existing container 2025-11-14 21:39:59 -08:00
celestial-vault 474c655240 update script documentation for next steps after docker build 2025-11-14 11:11:25 -08:00
celestial-vault b5157a2376 code comment 2025-11-14 11:02:53 -08:00
celestial-vault b14db72140 add docker setup for cli development 2025-11-13 21:31:54 -08:00
276 changed files with 6013 additions and 16645 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix: do not retry request automatically on auth failure.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add support for sonar and sonar-pro models to SAP AI Core Provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix task timeline display height.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Ensure tool arguments are streamed during file operations when native tool calling is enabled.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Added thinking level setting for Gemini 3.0 Pro
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Feat: add thought signature support for Gemini SDK
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Feat: Enable native tool calling for Baseten and Kimi K2 models
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Replaces generic robot icon with Cline logo across VS Code UI
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add Kimi K2 Thinking to Baseten Provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Skip MCP tool with invalid name (e.g. name too long) when native tool calling is enabled.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix Anthropic provider missing signature param when thinking is enabled.
+2 -2
View File
@@ -60,11 +60,11 @@ jobs:
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm install --include=optional
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm install --include=optional
run: cd webview-ui && npm ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
-21
View File
@@ -165,27 +165,6 @@
},
"console": "integratedTerminal",
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": "Open Storybook",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"storybook"
],
"cwd": "${workspaceFolder}/webview-ui",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"serverReadyAction": {
"pattern": "Local:.*http://localhost:([0-9]+)",
"uriFormat": "http://localhost:%s",
"action": "openExternally"
},
"env": {
"IS_DEV": "true"
}
}
]
}
-20
View File
@@ -263,26 +263,6 @@
"watch"
],
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
},
{
"type": "npm",
"script": "storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"label": "npm: storybook",
"dependsOn": [
"npm: protos",
"npm: build:webview"
],
"presentation": {
"reveal": "always"
},
"options": {
"env": {
"IS_DEV": "true"
}
}
}
],
"inputs": [
+6 -22
View File
@@ -1,30 +1,14 @@
# Changelog
## [3.38.1]
## 3.37.1
### Fixed
- Fixed handling of 'signature' field in sanitizeAnthropicContentBlock to properly preserve it when thinking is enabled, as required by Anthropic's API.
## [3.38.0]
### Added
- Gemini 3 Pro Preview model
- AquaVoice Avalon model for voice-to-text dictation
### Fixed
- Automatic context truncation when AWS Bedrock token usage rate limits are exceeded
- Removed new_task tool from system prompts, updated slash command prompts, and added helper function for native tool calling validation
## [3.37.1]
- Comprehensive changes to better support GPT 5.1 - System prompt, tools, deep-planning, focus chain, etc.
- Add AGENTS.md support
- feat(models): Add free minimax/mimax-m2 model to the model picker
- cf8dd1c: Comprehensive changes to better support GPT 5.1 - System prompt, tools, deep-planning, focus chain, etc.
- 02abbcf: Add AGENTS.md support
- 855db7d: feat(models): Add free minimax/mimax-m2 model to the model picker
## [3.37.0]
### Added
## Added
- GPT-5.1 with model-specific prompting: tailored system prompts, tool usage, focus chain, and deep-planning optimizations
- Nous Research provider with Hermes 4 model family and custom system prompts
@@ -34,7 +18,7 @@
- Expanded HTTP proxy support throughout the codebase
- Improved focus chain prompting for frontier models (Anthropic, OpenAI, Gemini, xAI)
### Fixed
## Fixed
- Duplicate tool results prevention through existence checking
- XML entity escaping in model content processor
-30
View File
@@ -1,30 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>
<json>
<![CDATA[
{
"fontFamily": "cline-bot",
"majorVersion": 1,
"minorVersion": 0,
"fontURL": "https://cline.bot",
"designerURL": "https://cline.bot",
"licenseURL": "https://cline.bot",
"version": "Version 1.0",
"fontId": "cline-bot",
"psName": "cline-bot",
"subFamily": "Regular",
"fullName": "cline-bot",
"description": "Font generated by IcoMoon."
}
]]>
</json>
</metadata>
<defs>
<font id="cline-bot" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="cline" data-tags="cline" horiz-adv-x="977" d="M964.553 383.11l-60.285 121.406v69.495c0 115.545-92.939 209.321-207.647 209.321h-102.986c7.536 15.071 11.722 32.654 11.722 51.074 0 64.471-51.912 116.383-115.545 116.383s-115.545-51.912-115.545-116.383 4.186-35.166 11.722-51.074h-102.986c-114.708 0-207.647-93.776-207.647-209.321v-69.495l-61.959-121.406c-5.861-11.722-5.861-26.793 0-38.515l61.959-119.732v-69.495c0-115.545 92.939-209.321 207.647-209.321h415.294c114.708 0 207.647 93.776 207.647 209.321v69.495l60.285 119.732c5.861 11.722 5.861 25.956 0 38.515v0zM426.178 284.311c0-52.749-42.702-95.451-94.613-95.451s-94.613 42.702-94.613 95.451v169.132c0 52.749 42.702 95.451 94.613 95.451s94.613-42.702 94.613-95.451v-169.132zM731.787 284.311c0-52.749-42.702-95.451-94.613-95.451s-94.613 42.702-94.613 95.451v169.132c0 52.749 42.702 95.451 94.613 95.451s94.613-42.702 94.613-95.451v-169.132z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.
Binary file not shown.
+12 -103
View File
@@ -14,7 +14,6 @@ import (
"github.com/cline/cli/pkg/cli/auth"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
"github.com/spf13/cobra"
@@ -69,31 +68,12 @@ see the manual page: man cline`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Get content from both args and stdin FIRST to determine if this is batch or interactive
prompt, err := getContentFromStdinAndArgs(args)
if err != nil {
return fmt.Errorf("failed to read prompt: %w", err)
}
// If no prompt provided, this is interactive mode - reject JSON
if prompt == "" {
// Check for JSON output mode - not supported for interactive mode
// Per the plan: Interactive commands output PLAIN TEXT errors, not JSON
if err := global.Config.MustNotBeJSON("interactive mode"); err != nil {
return fmt.Errorf("%w when no prompt is provided. Provide a prompt as an argument or use 'cline task new' instead", err)
}
}
var instanceAddress string
// If --address flag not provided, start instance BEFORE getting prompt
if !cmd.Flags().Changed("address") {
if global.Config.Verbose {
if global.Config.JsonFormat() {
output.OutputStatusMessage("verbose", "Starting new Cline instance", nil)
} else {
fmt.Println("Starting new Cline instance...")
}
fmt.Println("Starting new Cline instance...")
}
instance, err := global.Clients.StartNewInstance(ctx)
if err != nil {
@@ -101,36 +81,24 @@ see the manual page: man cline`,
}
instanceAddress = instance.Address
if global.Config.Verbose {
if global.Config.JsonFormat() {
output.OutputStatusMessage("verbose", "Started instance", map[string]interface{}{"address": instanceAddress})
} else {
fmt.Printf("Started instance at %s\n\n", instanceAddress)
}
fmt.Printf("Started instance at %s\n\n", instanceAddress)
}
// Set up cleanup on exit
defer func() {
if global.Config.Verbose {
if global.Config.JsonFormat() {
output.OutputStatusMessage("verbose", "Cleaning up instance", nil)
} else {
fmt.Println("\nCleaning up instance...")
}
fmt.Println("\nCleaning up instance...")
}
registry := global.Clients.GetRegistry()
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
if global.Config.Verbose {
if global.Config.JsonFormat() {
output.OutputStatusMessage("warning", "Failed to clean up instance", map[string]interface{}{"error": err.Error()})
} else {
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
}
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
}
}
}()
// Check if user has credentials configured (only needed in interactive mode)
if prompt == "" && !isUserReadyToUse(ctx, instanceAddress) {
// Check if user has credentials configured
if !isUserReadyToUse(ctx, instanceAddress) {
// Create renderer for welcome messages
renderer := display.NewRenderer(global.Config.OutputFormat)
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
@@ -155,6 +123,12 @@ see the manual page: man cline`,
instanceAddress = coreAddress
}
// Get content from both args and stdin
prompt, err := getContentFromStdinAndArgs(args)
if err != nil {
return fmt.Errorf("failed to read prompt: %w", err)
}
// If no prompt from args or stdin, show interactive input
if prompt == "" {
// Pass the mode flag to banner so it shows correct mode
@@ -210,72 +184,7 @@ see the manual page: man cline`,
rootCmd.AddCommand(cli.NewLogsCommand())
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
// Suppress Cobra's default error printing - we'll handle it ourselves
rootCmd.SilenceErrors = true
rootCmd.SilenceUsage = true
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
// Check if JSON mode is enabled
// If global.Config is nil (error during early validation), check os.Args directly
isJSONMode := false
if global.Config != nil {
isJSONMode = global.Config.JsonFormat()
} else {
// Check os.Args for --output-format json flag
for i, arg := range os.Args {
if (arg == "--output-format" || arg == "-F") && i+1 < len(os.Args) {
isJSONMode = os.Args[i+1] == "json"
break
}
// Handle --output-format=json format
if strings.HasPrefix(arg, "--output-format=") {
isJSONMode = strings.TrimPrefix(arg, "--output-format=") == "json"
break
}
}
}
// Per the plan: Interactive commands output **plain text errors**, NOT JSON
// Check if this is an interactive command error
errMsg := err.Error()
isInteractiveError := strings.Contains(errMsg, "interactive")
// Output error in appropriate format
if isJSONMode && !isInteractiveError {
// Try to extract command name from os.Args
commandName := "unknown"
if len(os.Args) > 1 {
// First arg after binary name is typically the command
// Skip flags (starting with -)
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if !strings.HasPrefix(arg, "-") {
commandName = arg
// For subcommands like "instance kill", join them
if i+1 < len(os.Args) && !strings.HasPrefix(os.Args[i+1], "-") {
commandName = arg + " " + os.Args[i+1]
}
break
}
}
}
// Output error as JSON to stderr
errorResponse := map[string]interface{}{
"status": "error",
"command": commandName,
"error": err.Error(),
}
if jsonBytes, marshalErr := json.MarshalIndent(errorResponse, "", " "); marshalErr == nil {
fmt.Fprintln(os.Stderr, string(jsonBytes))
} else {
// Fallback if JSON marshaling fails
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
} else {
// Output error as plain text in non-JSON modes
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
os.Exit(1)
}
}
-365
View File
@@ -1,365 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
)
// TestBatchModeCommands tests commands that are suitable for batch/automation
func TestBatchModeCommands(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start an instance for testing
_ = mustRunCLI(ctx, t, "instance", "new")
// Commands that should work in batch mode (non-interactive)
batchCommands := [][]string{
{"version", "--output-format", "json"},
{"instance", "list", "--output-format", "json"},
{"logs", "path", "--output-format", "json"},
{"logs", "list", "--output-format", "json"},
{"config", "list", "--output-format", "json"},
}
for _, cmd := range batchCommands {
t.Run(strings.Join(cmd[:len(cmd)-2], "-"), func(t *testing.T) {
out := mustRunCLI(ctx, t, cmd...)
// Should be valid JSON
if !json.Valid([]byte(out)) {
t.Errorf("batch command should output valid JSON: %v", cmd)
}
// Should complete without user interaction
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
// Should have success status
if status, ok := result["status"].(string); !ok || status != "success" {
t.Error("batch command should have success status")
}
})
}
}
// TestInteractiveCommandsInBatchMode tests that interactive commands fail appropriately
func TestInteractiveCommandsInBatchMode(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Interactive commands that should fail in batch mode (JSON)
interactiveCommands := [][]string{
{"auth", "--output-format", "json"},
}
for _, cmd := range interactiveCommands {
t.Run(strings.Join(cmd[:len(cmd)-2], "-"), func(t *testing.T) {
_, errOut, exitCode := runCLI(ctx, t, cmd...)
// Should fail (non-zero exit code)
if exitCode == 0 {
t.Error("interactive command should fail in batch mode")
}
// Error should be JSON in JSON mode (may not be implemented yet)
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Logf("Note: error not JSON: %v", err)
return
}
// Should indicate it's an interactive command
if errorMsg, ok := errorData["error"].(string); ok {
if !strings.Contains(strings.ToLower(errorMsg), "interactive") &&
!strings.Contains(strings.ToLower(errorMsg), "tty") {
t.Log("Note: error doesn't mention interactive/TTY requirement")
}
}
})
}
}
// TestJSONOutputForAutomation tests that JSON output is suitable for automation
func TestJSONOutputForAutomation(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Create instance
newOut := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json")
// Parse to extract instance address
var newData map[string]interface{}
if err := json.Unmarshal([]byte(newOut), &newData); err != nil {
t.Fatalf("failed to parse new instance JSON: %v", err)
}
// Extract address from structured data
data, ok := newData["data"].(map[string]interface{})
if !ok {
t.Fatal("JSON should have data field")
}
address, ok := data["address"].(string)
if !ok || address == "" {
t.Fatal("JSON should have address in data")
}
// Use extracted address in next command (automation scenario)
killOut := mustRunCLI(ctx, t, "instance", "kill", address, "--output-format", "json")
// Parse kill result
var killData map[string]interface{}
if err := json.Unmarshal([]byte(killOut), &killData); err != nil {
t.Fatalf("failed to parse kill JSON: %v", err)
}
// Verify success
if status, ok := killData["status"].(string); !ok || status != "success" {
t.Error("automation chain should succeed")
}
}
// TestPlainOutputForHumans tests that plain output is suitable for human consumption
func TestPlainOutputForHumans(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Plain output should be human-readable
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should have headers
if !strings.Contains(out, "ADDRESS") || !strings.Contains(out, "STATUS") {
t.Error("plain output should have readable headers")
}
// Should have instance data in readable format
if !strings.Contains(out, "127.0.0.1:") {
t.Error("plain output should show instance address")
}
}
// TestScriptableOutput tests output suitable for shell scripts
func TestScriptableOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Commands that produce simple, parseable output
tests := []struct {
name string
args []string
validate func(string) bool
}{
{
"version-short",
[]string{"version", "--short"},
func(out string) bool {
// Should be simple version string (may be "dev" in development builds)
trimmed := strings.TrimSpace(out)
return trimmed != "" && !strings.Contains(trimmed, "\n")
},
},
{
"logs-path",
[]string{"logs", "path"},
func(out string) bool {
// Should be simple path
return strings.Contains(out, "/") && !strings.Contains(strings.TrimSpace(out), "\n")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := mustRunCLI(ctx, t, tt.args...)
if !tt.validate(out) {
t.Errorf("output not suitable for scripting: %s", out)
}
})
}
}
// TestBatchProcessingMultipleCommands tests running multiple commands in sequence
func TestBatchProcessingMultipleCommands(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Batch create multiple instances
var addresses []string
for i := 0; i < 3; i++ {
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json")
var data map[string]interface{}
if err := json.Unmarshal([]byte(out), &data); err != nil {
t.Fatalf("failed to parse instance %d: %v", i, err)
}
if d, ok := data["data"].(map[string]interface{}); ok {
if addr, ok := d["address"].(string); ok {
addresses = append(addresses, addr)
}
}
}
// Verify all instances exist
listOut := mustRunCLI(ctx, t, "instance", "list", "--output-format", "json")
var listData map[string]interface{}
if err := json.Unmarshal([]byte(listOut), &listData); err != nil {
t.Fatalf("failed to parse list: %v", err)
}
// Count instances
if data, ok := listData["data"].(map[string]interface{}); ok {
if instances, ok := data["instances"].([]interface{}); ok {
if len(instances) != 3 {
t.Errorf("expected 3 instances, got %d", len(instances))
}
}
}
// Batch kill all
killOut := mustRunCLI(ctx, t, "instance", "kill", "--all-cli", "--output-format", "json")
var killData map[string]interface{}
if err := json.Unmarshal([]byte(killOut), &killData); err != nil {
t.Fatalf("failed to parse kill: %v", err)
}
// Verify all killed
if data, ok := killData["data"].(map[string]interface{}); ok {
if killedCount, ok := data["killedCount"].(float64); ok {
if int(killedCount) != 3 {
t.Errorf("expected 3 killed, got %d", int(killedCount))
}
}
}
}
// TestNonInteractiveDefaults tests that defaults work without user interaction
func TestNonInteractiveDefaults(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Commands should use sensible defaults
out := mustRunCLI(ctx, t, "version")
// Should complete successfully with default settings
if strings.TrimSpace(out) == "" {
t.Error("version should have output")
}
// Should not prompt for input
if strings.Contains(strings.ToLower(out), "enter") ||
strings.Contains(strings.ToLower(out), "input") {
t.Error("non-interactive command should not prompt")
}
}
// TestOutputRedirection tests that output works with shell redirection
func TestOutputRedirection(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// JSON output should be complete and parseable
out := mustRunCLI(ctx, t, "version", "--output-format", "json")
// Should be valid JSON (as if redirected to file)
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Error("redirected JSON should be parseable")
}
// Should not have extra output on stdout
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) > 1 {
// Check if all lines are JSON (JSONL case)
allJSON := true
for _, line := range lines {
if !json.Valid([]byte(line)) {
allJSON = false
break
}
}
if !allJSON {
t.Error("multi-line output should be JSONL or single JSON")
}
}
}
// TestErrorsInBatchMode tests error handling in batch/automation contexts
func TestErrorsInBatchMode(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Errors should be machine-readable in JSON mode
_, errOut, exitCode := runCLI(ctx, t, "instance", "kill", "nonexistent:9999", "--output-format", "json")
// Should have error exit code
if exitCode == 0 {
t.Error("error should have non-zero exit code")
}
// Error should be parseable JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Error("batch mode errors should be JSON")
}
// Should have structured error information
if _, ok := errorData["error"]; !ok {
t.Error("error JSON should have error field")
}
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Error("error JSON should have status=error")
}
}
// TestStdoutStderrSeparation tests that output and errors use correct streams
func TestStdoutStderrSeparation(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Success output should go to stdout
out, errOut, exitCode := runCLI(ctx, t, "version", "--output-format", "json")
if exitCode != 0 {
t.Fatal("version should succeed")
}
// Success should be on stdout
if out == "" {
t.Error("success output should be on stdout")
}
// Nothing on stderr for success
if errOut != "" {
t.Log("Warning: success command has stderr output:", errOut)
}
// Error output should go to stderr
// Use a real command that will fail (not "nonexistent" which hangs waiting for TTY)
out, errOut, exitCode = runCLI(ctx, t, "instance", "kill", "nonexistent:9999", "--output-format", "json")
if exitCode == 0 {
t.Fatal("invalid command should fail")
}
// Error should be on stderr
if errOut == "" {
t.Error("error output should be on stderr")
}
}
+91
View File
@@ -2,8 +2,10 @@ package e2e
import (
"context"
"encoding/json"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
@@ -37,6 +39,95 @@ func TestMultiInstanceDefaultUnchanged(t *testing.T) {
}
}
// 6. Default.json update after removal of current default
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start two instances
_ = mustRunCLI(ctx, t, "instance", "new")
_ = mustRunCLI(ctx, t, "instance", "new")
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) < 2 {
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
}
// Choose second as new default
target := out.CoreInstances[1]
waitForAddressHealthy(t, target.Address, defaultTimeout)
// Set as default
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
// Verify default switched
out = listInstancesJSON(ctx, t)
if out.DefaultInstance != target.Address {
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
}
// Kill the default instance using runtime PID discovery
corePID := getCorePID(t, target.Address)
if corePID <= 0 {
t.Fatalf("could not find PID for core process at %s", target.Address)
}
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
// Wait for removal
waitForAddressRemoved(t, target.Address, longTimeout)
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
findAndKillHostProcess(t, target.HostPort())
// Ensure default_instance updated to another available instance (or removed if none remain)
out = listInstancesJSON(ctx, t)
// If there are instances left, default_instance must be one of them
if len(out.CoreInstances) > 0 {
found := false
for _, it := range out.CoreInstances {
if out.DefaultInstance == it.Address {
found = true
break
}
}
if !found {
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
}
} else {
// No instances remain; cli-default-instance.json should be removed
clineDir := getClineDir(t)
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if _, err := os.Stat(defPath); err == nil {
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
}
}
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
clineDir := getClineDir(t)
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if len(out.CoreInstances) > 0 {
raw, err := os.ReadFile(defPath)
if err != nil {
t.Fatalf("read cli-default-instance.json: %v", err)
}
var tmp struct {
DefaultInstance string `json:"default_instance"`
}
if err := json.Unmarshal(raw, &tmp); err != nil {
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
}
if tmp.DefaultInstance != out.DefaultInstance {
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
}
}
}
// 11. SQLite database missing (edge): list succeeds and returns empty set
func TestRegistryDirMissingEdge(t *testing.T) {
clineDir := setTempClineDir(t)
-467
View File
@@ -1,467 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
)
// TestJSONErrorOutput tests that errors in JSON mode are properly formatted
func TestJSONErrorOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test error with a real command that will fail
// (not "nonexistent" which hangs waiting for TTY in interactive mode)
_, errOut, exitCode := runCLI(ctx, t, "instance", "kill", "nonexistent:9999", "--output-format", "json")
// Should have non-zero exit code
if exitCode == 0 {
t.Error("invalid command should have non-zero exit code")
}
// Error output should be JSON in JSON mode
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Logf("Note: error output not JSON: %v", err)
return
}
// Should have error status
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Error("error response should have status=error")
}
}
// TestPlainErrorOutput tests that errors in plain mode are human-readable
func TestPlainErrorOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test invalid instance address
_, errOut, exitCode := runCLI(ctx, t, "instance", "kill", "invalid:address", "--output-format", "plain")
// Should have non-zero exit code
if exitCode == 0 {
t.Error("invalid address should have non-zero exit code")
}
// Error should NOT be JSON
if json.Valid([]byte(errOut)) {
t.Error("plain mode errors should not be JSON")
}
// Should contain error message
if errOut == "" {
t.Error("plain mode should output error message")
}
}
// TestRichErrorOutput tests that errors in rich mode are human-readable
func TestRichErrorOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test invalid command
_, errOut, exitCode := runCLI(ctx, t, "instance", "default", "nonexistent:address", "--output-format", "rich")
// Should have non-zero exit code
if exitCode == 0 {
t.Error("invalid address should have non-zero exit code")
}
// Error should NOT be JSON
if json.Valid([]byte(errOut)) {
t.Error("rich mode errors should not be JSON")
}
// Should contain error message
if errOut == "" {
t.Error("rich mode should output error message")
}
}
// TestErrorExitCodes tests that different error types have appropriate exit codes
func TestErrorExitCodes(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
args []string
expectNonZero bool
}{
// Note: "invalid-command" test removed because by design, the CLI accepts
// any text as a task prompt (e.g., "cline nonexistent" treats "nonexistent"
// as a prompt, not an invalid command)
{"invalid-flag", []string{"version", "--nonexistent-flag"}, true},
{"invalid-address", []string{"instance", "kill", "invalid"}, true},
{"valid-command", []string{"version", "--output-format", "json"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, exitCode := runCLI(ctx, t, tt.args...)
if tt.expectNonZero && exitCode == 0 {
t.Errorf("%s should have non-zero exit code", tt.name)
}
if !tt.expectNonZero && exitCode != 0 {
t.Errorf("%s should have zero exit code, got %d", tt.name, exitCode)
}
})
}
}
// TestJSONErrorStructure tests the structure of JSON error responses
func TestJSONErrorStructure(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
_, errOut, _ := runCLI(ctx, t, "instance", "kill", "invalid:99999", "--output-format", "json")
// Parse error JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Fatalf("error output should be valid JSON: %v", err)
}
// Check required fields
if _, ok := errorData["status"]; !ok {
t.Error("JSON error should have 'status' field")
}
if _, ok := errorData["command"]; !ok {
t.Error("JSON error should have 'command' field")
}
if _, ok := errorData["error"]; !ok {
t.Error("JSON error should have 'error' field")
}
}
// TestPartialCommandFailure tests handling of partially successful commands
func TestPartialCommandFailure(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start multiple instances
_ = mustRunCLI(ctx, t, "instance", "new", "--output-format", "json")
_ = mustRunCLI(ctx, t, "instance", "new", "--output-format", "json")
// Try to kill with --all-cli (should succeed)
out := mustRunCLI(ctx, t, "instance", "kill", "--all-cli", "--output-format", "json")
// Should be valid JSON
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("output should be valid JSON: %v", err)
}
// Should report success
if status, ok := result["status"].(string); !ok || status != "success" {
t.Error("successful kill should have status=success")
}
}
// TestMissingRequiredParameter tests error handling for missing parameters
func TestMissingRequiredParameter(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
args []string
}{
{"instance-kill-no-address", []string{"instance", "kill", "--output-format", "json"}},
{"instance-default-no-address", []string{"instance", "default", "--output-format", "json"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, errOut, exitCode := runCLI(ctx, t, tt.args...)
// Should fail
if exitCode == 0 {
t.Error("missing required parameter should fail")
}
// In JSON mode, error should be JSON
if strings.Contains(tt.args[len(tt.args)-1], "json") {
if !json.Valid([]byte(errOut)) {
t.Error("JSON mode error should be valid JSON")
}
}
})
}
}
// TestInvalidFlagCombinations tests error handling for invalid flag combinations
func TestInvalidFlagCombinations(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test invalid output format
_, errOut, exitCode := runCLI(ctx, t, "version", "--output-format", "invalid")
// Should fail
if exitCode == 0 {
t.Error("invalid output format should fail")
}
// Should mention the invalid value
if !strings.Contains(errOut, "invalid") {
t.Error("error should mention invalid format")
}
}
// TestNetworkErrorHandling tests handling of network/connection errors
func TestNetworkErrorHandling(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Try to connect to non-existent instance
_, errOut, exitCode := runCLI(ctx, t, "instance", "kill", "127.0.0.1:99999", "--output-format", "json")
// Should fail gracefully
if exitCode == 0 {
t.Error("connection to non-existent instance should fail")
}
// In JSON mode, should return JSON error
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Errorf("connection error in JSON mode should be JSON: %v", err)
}
}
// TestErrorMessageClarity tests that error messages are clear and actionable
func TestErrorMessageClarity(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
args []string
shouldContain []string
}{
// Note: "invalid-command" test removed for same reason as in TestErrorExitCodes
// The CLI by design accepts any text as a task prompt
{
"invalid-instance",
[]string{"instance", "default", "nonexistent:9999", "--output-format", "plain"},
[]string{"not found", "instance"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, errOut, exitCode := runCLI(ctx, t, tt.args...)
// Should fail
if exitCode == 0 {
t.Error("invalid command should fail")
}
// Should contain helpful keywords
errOutLower := strings.ToLower(errOut)
for _, keyword := range tt.shouldContain {
if !strings.Contains(errOutLower, strings.ToLower(keyword)) {
t.Errorf("error message should contain '%s', got: %s", keyword, errOut)
}
}
})
}
}
// TestConcurrentErrors tests error handling with concurrent operations
func TestConcurrentErrors(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Try to set default to non-existent instance
_, errOut, exitCode := runCLI(ctx, t, "instance", "default", "nonexistent:9999", "--output-format", "json")
// Should fail
if exitCode == 0 {
t.Error("setting non-existent default should fail")
}
// Error should be JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Errorf("error should be JSON: %v", err)
}
// Original instance should still be default
listOut := mustRunCLI(ctx, t, "instance", "list", "--output-format", "json")
var listData map[string]interface{}
if err := json.Unmarshal([]byte(listOut), &listData); err != nil {
t.Fatalf("list output should be JSON: %v", err)
}
// Default should not have changed
if data, ok := listData["data"].(map[string]interface{}); ok {
if defaultInstance, ok := data["defaultInstance"].(string); ok && defaultInstance == "nonexistent:9999" {
t.Error("default instance should not have changed after failed update")
}
}
}
// TestJSONErrorInstanceKillNotInRegistry tests error when killing instance not in registry
func TestJSONErrorInstanceKillNotInRegistry(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Try to kill instance that doesn't exist in registry
// Use a valid address format but not a registered instance
_, errOut, exitCode := runCLI(ctx, t, "instance", "kill", "localhost:5000", "--output-format", "json")
// Should fail
if exitCode == 0 {
t.Error("killing non-existent instance should fail")
}
// Error should be JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Fatalf("error output should be valid JSON: %v\nOutput: %s", err, errOut)
}
// Should have error status
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Errorf("expected status=error, got %v", errorData["status"])
}
// Should have error message mentioning "not found"
if errMsg, ok := errorData["error"].(string); ok {
if !strings.Contains(strings.ToLower(errMsg), "not found") {
t.Errorf("error message should mention 'not found', got: %s", errMsg)
}
}
}
// TestJSONErrorConfigGetInvalid tests error when getting invalid config key
func TestJSONErrorConfigGetInvalid(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Try to get non-existent config key
_, errOut, exitCode := runCLI(ctx, t, "config", "get", "invalid.nonexistent.key", "--output-format", "json")
// Should fail
if exitCode == 0 {
t.Error("getting invalid config key should fail")
}
// Error should be JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Fatalf("error output should be valid JSON: %v\nOutput: %s", err, errOut)
}
// Should have error status
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Errorf("expected status=error, got %v", errorData["status"])
}
// Should have error field
if _, ok := errorData["error"]; !ok {
t.Error("error response should have error field")
}
}
// TestJSONErrorTaskOpenNonexistent tests error when opening nonexistent task
func TestJSONErrorTaskOpenNonexistent(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Try to open non-existent task with high ID that won't exist
_, errOut, exitCode := runCLI(ctx, t, "task", "open", "99999", "--output-format", "json")
// Should fail
if exitCode == 0 {
t.Error("opening non-existent task should fail")
}
// Error should be JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Fatalf("error output should be valid JSON: %v\nOutput: %s", err, errOut)
}
// Should have error status
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Errorf("expected status=error, got %v", errorData["status"])
}
// Should have error message
if errMsg, ok := errorData["error"].(string); ok {
if !strings.Contains(strings.ToLower(errMsg), "not found") &&
!strings.Contains(strings.ToLower(errMsg), "does not exist") {
t.Logf("Note: error message format: %s", errMsg)
}
}
}
// TestJSONErrorInstanceDefaultDetailed tests comprehensive error scenarios for instance default
func TestJSONErrorInstanceDefaultDetailed(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
address string
shouldContain string
}{
{
name: "invalid-port",
address: "localhost:99999",
shouldContain: "not found",
},
{
name: "invalid-address",
address: "nonexistent:9999",
shouldContain: "not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Try to set invalid instance as default
_, errOut, exitCode := runCLI(ctx, t, "instance", "default", tt.address, "--output-format", "json")
// Should fail
if exitCode == 0 {
t.Errorf("setting invalid default %s should fail", tt.address)
}
// Error should be JSON
var errorData map[string]interface{}
if err := json.Unmarshal([]byte(errOut), &errorData); err != nil {
t.Fatalf("error output should be valid JSON: %v", err)
}
// Should have error status
if status, ok := errorData["status"].(string); !ok || status != "error" {
t.Errorf("expected status=error, got %v", errorData["status"])
}
// Check error message content
if errMsg, ok := errorData["error"].(string); ok {
if !strings.Contains(strings.ToLower(errMsg), tt.shouldContain) {
t.Errorf("error message should contain '%s', got: %s", tt.shouldContain, errMsg)
}
}
})
}
}
File diff suppressed because it is too large Load Diff
+20 -43
View File
@@ -51,42 +51,17 @@ func setTempClineDir(t *testing.T) string {
return clineDir
}
// setTempClineDirWithManualCleanup creates a temp CLINE_DIR and registers
// a cleanup handler that attempts to remove the directory tree but ignores
// errors. This is useful for tests that create git checkpoints or other
// async file operations that may not complete before test cleanup.
func setTempClineDirWithManualCleanup(t *testing.T) string {
t.Helper()
// Create temp dir but don't use t.TempDir() to avoid automatic cleanup
dir, err := os.MkdirTemp("", "cline-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
clineDir := filepath.Join(dir, ".cline")
if err := os.MkdirAll(clineDir, 0o755); err != nil {
t.Fatalf("mkdir clineDir: %v", err)
}
t.Setenv("CLINE_DIR", clineDir)
// Register cleanup that attempts removal but ignores errors
t.Cleanup(func() {
// Give async operations time to complete
time.Sleep(100 * time.Millisecond)
// Attempt to remove, but don't fail if it errors
// (git checkpoints may still have open file handles)
_ = os.RemoveAll(dir)
})
return clineDir
}
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
t.Helper()
bin := repoAwareBinPath(t)
// CLI uses CLINE_DIR environment variable which is already set by setTempClineDir
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
// Prepend persistent flag so Cobra sees it regardless of subcommand position
args = append([]string{"--config", clineDir}, args...)
}
cmd := exec.CommandContext(ctx, bin, args...)
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
if wd, err := os.Getwd(); err == nil {
@@ -95,23 +70,12 @@ func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string,
}
// propagate env including CLINE_DIR
cmd.Env = os.Environ()
// Set stdin to empty reader to prevent hanging on stdin reads
// Without this, commands that try to read stdin (like root command with invalid args)
// will block forever waiting for input
cmd.Stdin = strings.NewReader("")
outB, errB := &strings.Builder{}, &strings.Builder{}
cmd.Stdout = outB
cmd.Stderr = errB
err := cmd.Run()
exit := 0
if err != nil {
// Check for timeout first - this should fail the test
if ctx.Err() == context.DeadlineExceeded {
t.Fatalf("Command timed out (context deadline exceeded): %v\nCommand: %s %v\nStdout: %s\nStderr: %s",
err, bin, args, outB.String(), errB.String())
}
// Extract exit code if possible
if ee, ok := err.(*exec.ExitError); ok {
exit = ee.ExitCode()
@@ -214,6 +178,19 @@ func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
})
}
func findFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen 127.0.0.1:0: %v", err)
}
defer l.Close()
_, portStr, _ := net.SplitHostPort(l.Addr().String())
var port int
fmt.Sscanf(portStr, "%d", &port)
return port
}
func getClineDir(t *testing.T) string {
t.Helper()
clineDir := os.Getenv("CLINE_DIR")
+120
View File
@@ -0,0 +1,120 @@
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
func TestMixedLocalhostVs127Coexist(t *testing.T) {
clineDir := setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start one instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Get the running instance and its port/PID
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) == 0 {
t.Fatalf("expected at least 1 instance")
}
inst := out.CoreInstances[0]
waitForAddressHealthy(t, inst.Address, defaultTimeout)
// Manually add a SQLite entry for the same port but 127.0.0.1 host
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
t.Fatalf("insert 127 alias entry: %v", err)
}
// Verify both addresses appear and are healthy
waitForAddressHealthy(t, inst.Address, defaultTimeout)
waitForAddressHealthy(t, addr127, defaultTimeout)
out = listInstancesJSON(ctx, t)
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
}
}
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
func TestStartStopStress(t *testing.T) {
_ = setTempClineDir(t)
for i := 0; i < 3; i++ { // keep small for CI time
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Snapshot current addresses
before := listInstancesJSON(ctx, t)
beforeSet := map[string]struct{}{}
for _, it := range before.CoreInstances {
beforeSet[it.Address] = struct{}{}
}
// Start a new instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Find the new instance address
var newAddr string
waitFor(t, defaultTimeout, func() (bool, string) {
after := listInstancesJSON(ctx, t)
for _, it := range after.CoreInstances {
if _, ok := beforeSet[it.Address]; !ok {
newAddr = it.Address
return true, ""
}
}
return false, "new instance address not detected yet"
})
// Wait healthy
waitForAddressHealthy(t, newAddr, defaultTimeout)
// Get PID using runtime discovery and kill it
after := listInstancesJSON(ctx, t)
info, ok := getByAddress(after, newAddr)
if !ok {
t.Fatalf("new instance %s missing", newAddr)
}
// Get PID using runtime discovery
corePID := getCorePID(t, info.Address)
if corePID <= 0 {
t.Fatalf("could not find PID for new instance at %s", info.Address)
}
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
// Wait removed from SQLite database
waitForAddressRemoved(t, newAddr, longTimeout)
// Verify instance is removed from SQLite database
clineDir := os.Getenv("CLINE_DIR")
if clineDir != "" {
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
}
}
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
findAndKillHostProcess(t, info.HostPort())
// Verify both ports are now free
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
}
}
-304
View File
@@ -1,304 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
)
// TestPlainOutputVersion tests version command plain output
func TestPlainOutputVersion(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test plain output
out := mustRunCLI(ctx, t, "version", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain version information
if !strings.Contains(out, "Cline CLI") {
t.Error("plain output missing 'Cline CLI'")
}
// Should be readable text
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) == 0 {
t.Error("plain output should have content")
}
}
// TestPlainOutputVersionShort tests that --short flag works with plain
func TestPlainOutputVersionShort(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "version", "--short", "--output-format", "plain")
// Should be single line version number
trimmed := strings.TrimSpace(out)
if strings.Contains(trimmed, "\n") {
t.Error("--short output should be single line")
}
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("--short output should not be JSON")
}
}
// TestPlainOutputInstanceList tests instance list plain output
func TestPlainOutputInstanceList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start an instance first
_ = mustRunCLI(ctx, t, "instance", "new")
// Test plain output
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain table headers
if !strings.Contains(out, "ADDRESS") {
t.Error("plain output missing ADDRESS header")
}
if !strings.Contains(out, "STATUS") {
t.Error("plain output missing STATUS header")
}
// Should contain instance data
if !strings.Contains(out, "127.0.0.1:") {
t.Error("plain output missing instance address")
}
}
// TestPlainOutputInstanceNew tests instance new plain output
func TestPlainOutputInstanceNew(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain success message
if !strings.Contains(out, "Successfully started new instance") {
t.Error("plain output missing success message")
}
// Should contain address information
if !strings.Contains(out, "Address:") {
t.Error("plain output missing address information")
}
}
// TestPlainOutputInstanceKill tests instance kill plain output
func TestPlainOutputInstanceKill(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Create an instance to kill
newOut := mustRunCLI(ctx, t, "instance", "new", "--output-format", "plain")
// Extract address from plain text output
lines := strings.Split(newOut, "\n")
var address string
for _, line := range lines {
if strings.Contains(line, "Address:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
address = parts[1]
break
}
}
}
if address == "" {
t.Fatal("failed to extract address from plain output")
}
// Kill the instance
out := mustRunCLI(ctx, t, "instance", "kill", address, "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain success message
if !strings.Contains(out, "Successfully killed") {
t.Error("plain output missing success message")
}
}
// TestPlainOutputLogsPath tests logs path plain output
func TestPlainOutputLogsPath(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "logs", "path", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain a path
if !strings.Contains(out, "/") {
t.Error("plain output should contain a file path")
}
// Should be a simple path (one line)
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 1 {
t.Error("logs path should output single line")
}
}
// TestPlainOutputLogsList tests logs list plain output
func TestPlainOutputLogsList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "logs", "list", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should have content (either "No log files" or table)
if strings.TrimSpace(out) == "" {
t.Error("plain output should have content")
}
}
// TestPlainOutputConfigList tests config list plain output
func TestPlainOutputConfigList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
out := mustRunCLI(ctx, t, "config", "list", "--output-format", "plain")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should contain config information
if !strings.Contains(out, "Settings") && !strings.Contains(out, "mode") {
t.Error("plain output should contain config information")
}
}
// TestPlainOutputWithVerbose tests that verbose flag works with plain
func TestPlainOutputWithVerbose(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Test with verbose flag
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "plain", "--verbose")
// Should NOT be JSON or JSONL
lines := strings.Split(strings.TrimSpace(out), "\n")
for _, line := range lines {
if json.Valid([]byte(line)) {
t.Error("plain verbose output should not contain JSON lines")
}
}
// Should still be readable plain text
if !strings.Contains(out, "ADDRESS") {
t.Error("plain verbose output missing expected content")
}
}
// TestPlainOutputInteractiveCommands removed - interactive commands like `auth`
// cannot be tested in batch mode because they require a TTY. The `auth` command
// starts an instance (3s) then tries to display an interactive menu with huh,
// which hangs waiting for TTY access that will never be available in tests.
// TestPlainOutputNoJSON tests that plain output never contains JSON
func TestPlainOutputNoJSON(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
commands := [][]string{
{"version", "--output-format", "plain"},
{"logs", "path", "--output-format", "plain"},
{"logs", "list", "--output-format", "plain"},
}
for _, cmd := range commands {
out := mustRunCLI(ctx, t, cmd...)
// Should NOT be valid JSON
if json.Valid([]byte(out)) {
t.Errorf("command %v produced JSON in plain mode", cmd)
}
// Should have some content
if strings.TrimSpace(out) == "" {
t.Errorf("command %v produced empty output", cmd)
}
}
}
// TestPlainOutputReadable tests that plain output is human-readable
func TestPlainOutputReadable(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance for list command
_ = mustRunCLI(ctx, t, "instance", "new")
tests := []struct {
name string
args []string
}{
{"version", []string{"version", "--output-format", "plain"}},
{"instance-list", []string{"instance", "list", "--output-format", "plain"}},
{"logs-path", []string{"logs", "path", "--output-format", "plain"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := mustRunCLI(ctx, t, tt.args...)
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("plain output should not be JSON")
}
// Should be ASCII text (no control characters except newlines/tabs)
for _, char := range out {
if char < 32 && char != '\n' && char != '\t' && char != '\r' {
t.Errorf("plain output contains control character: %d", char)
}
}
// Should have content
if strings.TrimSpace(out) == "" {
t.Error("plain output should not be empty")
}
})
}
}
-422
View File
@@ -1,422 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
// TestRichOutputVersion tests version command rich output
func TestRichOutputVersion(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test rich output (default)
out := mustRunCLI(ctx, t, "version", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should contain version information
if !strings.Contains(out, "Cline CLI") {
t.Error("rich output missing 'Cline CLI'")
}
// Should be readable text
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) == 0 {
t.Error("rich output should have content")
}
}
// TestRichOutputVersionShort tests that --short flag works with rich
func TestRichOutputVersionShort(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "version", "--short", "--output-format", "rich")
// Should be single line version number
trimmed := strings.TrimSpace(out)
if strings.Contains(trimmed, "\n") {
t.Error("--short output should be single line")
}
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("--short output should not be JSON")
}
}
// TestRichOutputInstanceList tests instance list rich output
func TestRichOutputInstanceList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start an instance first
_ = mustRunCLI(ctx, t, "instance", "new")
// Test rich output
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Rich format uses markdown tables, so check for table markers
// Should contain table-like content
if !strings.Contains(out, "ADDRESS") && !strings.Contains(out, "|") {
t.Error("rich output should contain table markers or headers")
}
// Should contain instance data
if !strings.Contains(out, "127.0.0.1:") {
t.Error("rich output missing instance address")
}
}
// TestRichOutputInstanceNew tests instance new rich output
func TestRichOutputInstanceNew(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should contain success message
if !strings.Contains(out, "Successfully started new instance") {
t.Error("rich output missing success message")
}
// Should contain address information
if !strings.Contains(out, "Address:") {
t.Error("rich output missing address information")
}
}
// TestRichOutputInstanceKill tests instance kill rich output
func TestRichOutputInstanceKill(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Create an instance to kill
newOut := mustRunCLI(ctx, t, "instance", "new", "--output-format", "rich")
// Extract address from rich text output
lines := strings.Split(newOut, "\n")
var address string
for _, line := range lines {
if strings.Contains(line, "Address:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
address = parts[1]
break
}
}
}
if address == "" {
t.Fatal("failed to extract address from rich output")
}
// Kill the instance
out := mustRunCLI(ctx, t, "instance", "kill", address, "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should contain success message
if !strings.Contains(out, "Successfully killed") {
t.Error("rich output missing success message")
}
}
// TestRichOutputLogsPath tests logs path rich output
func TestRichOutputLogsPath(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "logs", "path", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should contain a path
if !strings.Contains(out, "/") {
t.Error("rich output should contain a file path")
}
// Should be a simple path (one line)
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 1 {
t.Error("logs path should output single line")
}
}
// TestRichOutputLogsList tests logs list rich output
func TestRichOutputLogsList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "logs", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should have content (either "No log files" or table)
if strings.TrimSpace(out) == "" {
t.Error("rich output should have content")
}
}
// TestRichOutputConfigList tests config list rich output
func TestRichOutputConfigList(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
out := mustRunCLI(ctx, t, "config", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should contain config information
if !strings.Contains(out, "Settings") && !strings.Contains(out, "mode") {
t.Error("rich output should contain config information")
}
}
// TestRichOutputWithVerbose tests that verbose flag works with rich
func TestRichOutputWithVerbose(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Test with verbose flag
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "rich", "--verbose")
// Should NOT be JSON or JSONL
lines := strings.Split(strings.TrimSpace(out), "\n")
for _, line := range lines {
if json.Valid([]byte(line)) {
t.Error("rich verbose output should not contain JSON lines")
}
}
// Should still have table or formatted content
if strings.TrimSpace(out) == "" {
t.Error("rich verbose output should have content")
}
}
// TestRichOutputInteractiveCommands removed - interactive commands like `auth`
// cannot be tested in batch mode because they require a TTY. The `auth` command
// starts an instance (3s) then tries to display an interactive menu with huh,
// which hangs waiting for TTY access that will never be available in tests.
// TestRichOutputNoJSON tests that rich output never contains JSON
func TestRichOutputNoJSON(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
commands := [][]string{
{"version", "--output-format", "rich"},
{"logs", "path", "--output-format", "rich"},
{"logs", "list", "--output-format", "rich"},
}
for _, cmd := range commands {
out := mustRunCLI(ctx, t, cmd...)
// Should NOT be valid JSON
if json.Valid([]byte(out)) {
t.Errorf("command %v produced JSON in rich mode", cmd)
}
// Should have some content
if strings.TrimSpace(out) == "" {
t.Errorf("command %v produced empty output", cmd)
}
}
}
// TestRichOutputFormatted tests that rich output may contain formatting
func TestRichOutputFormatted(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance for list command
_ = mustRunCLI(ctx, t, "instance", "new")
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Rich format may contain ANSI codes for colors (optional)
// Or markdown table formatting
// Just verify it's not plain JSON and has content
if strings.TrimSpace(out) == "" {
t.Error("rich output should not be empty")
}
// Should contain actual data
if !strings.Contains(out, "127.0.0.1:") {
t.Error("rich output should contain instance data")
}
}
// TestRichOutputDefaultFormat tests that rich is the default format
func TestRichOutputDefaultFormat(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test without --output-format flag (should default to rich)
outDefault := mustRunCLI(ctx, t, "version")
outRich := mustRunCLI(ctx, t, "version", "--output-format", "rich")
// Both should NOT be JSON
if json.Valid([]byte(outDefault)) {
t.Error("default output should not be JSON")
}
if json.Valid([]byte(outRich)) {
t.Error("rich output should not be JSON")
}
// Default and rich should be similar (both are rich format)
// They may differ slightly in formatting but should have same content
if !strings.Contains(outDefault, "Cline CLI") {
t.Error("default output missing version info")
}
if !strings.Contains(outRich, "Cline CLI") {
t.Error("rich output missing version info")
}
}
// TestRichOutputReadable tests that rich output is human-readable
func TestRichOutputReadable(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance for list command
_ = mustRunCLI(ctx, t, "instance", "new")
tests := []struct {
name string
args []string
}{
{"version", []string{"version", "--output-format", "rich"}},
{"instance-list", []string{"instance", "list", "--output-format", "rich"}},
{"logs-path", []string{"logs", "path", "--output-format", "rich"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := mustRunCLI(ctx, t, tt.args...)
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Should be mostly ASCII text (may have ANSI codes for colors)
// Just verify it's not binary garbage
if strings.TrimSpace(out) == "" {
t.Error("rich output should not be empty")
}
// Should be valid UTF-8
if !utf8.ValidString(out) {
t.Error("rich output should be valid UTF-8")
}
})
}
}
// TestRichOutputTableFormat tests that rich output uses tables where appropriate
func TestRichOutputTableFormat(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Rich format may use markdown tables or formatted tables
// Check for table-like structure (pipe characters or structured layout)
hasTableStructure := strings.Contains(out, "|") ||
strings.Contains(out, "ADDRESS") ||
strings.Contains(out, "STATUS")
if !hasTableStructure {
t.Error("rich output should have table-like structure")
}
// Should contain actual data
if !strings.Contains(out, "127.0.0.1:") {
t.Error("rich output missing instance data")
}
}
// TestRichOutputColorCodes tests that rich output may contain ANSI color codes
func TestRichOutputColorCodes(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Start instance
_ = mustRunCLI(ctx, t, "instance", "new")
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", "rich")
// Should NOT be JSON
if json.Valid([]byte(out)) {
t.Error("rich output should not be JSON")
}
// Rich format may contain ANSI escape codes for colors
// This is optional but common for rich output
// Just verify the output is valid and has content
if strings.TrimSpace(out) == "" {
t.Error("rich output should have content")
}
// If it has ANSI codes, they should be valid escape sequences
// (starting with \033[ or \x1b[)
// But this is optional, so we just check it's not breaking the output
lines := strings.Split(out, "\n")
for _, line := range lines {
// Each line should be valid UTF-8
if !utf8.ValidString(line) {
t.Error("rich output lines should be valid UTF-8")
}
}
}
+24 -34
View File
@@ -5,8 +5,6 @@ import (
"fmt"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
@@ -22,16 +20,9 @@ func TestStartAndList(t *testing.T) {
startOutput := mustRunCLI(ctx, t, "instance", "new")
t.Logf("Instance start output: %s", startOutput)
// Wait for instance to register in SQLite
t.Logf("Waiting for instance registration in SQLite...")
var out common.InstancesOutput
waitFor(t, defaultTimeout, func() (bool, string) {
out = listInstancesJSON(ctx, t)
if len(out.CoreInstances) > 0 {
return true, ""
}
return false, "waiting for instance to appear in registry"
})
t.Logf("Listing instances to check registration...")
// It should appear healthy in list JSON and be the default.
out := listInstancesJSON(ctx, t)
t.Logf("Found %d instances after start", len(out.CoreInstances))
if len(out.CoreInstances) != 1 {
@@ -67,19 +58,9 @@ func TestTaskNewDefault(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start one instance
// Start one instance and wait for healthy
_ = mustRunCLI(ctx, t, "instance", "new")
// Wait for instance to register in SQLite
var out common.InstancesOutput
waitFor(t, defaultTimeout, func() (bool, string) {
out = listInstancesJSON(ctx, t)
if len(out.CoreInstances) > 0 {
return true, ""
}
return false, "waiting for instance to appear in registry"
})
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
@@ -90,6 +71,24 @@ func TestTaskNewDefault(t *testing.T) {
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
}
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
func TestExplicitAddressAutoStart(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Find a free port and use explicit address. This should auto-start an instance.
port := findFreePort(t)
addr := "localhost:" + itoa(port)
// Run a task at explicit address (auto-start path)
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
// Verify the instance is present and healthy
waitForAddressHealthy(t, addr, defaultTimeout)
}
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
func TestCrashCleanup(t *testing.T) {
@@ -102,16 +101,7 @@ func TestCrashCleanup(t *testing.T) {
_ = mustRunCLI(ctx, t, "instance", "new")
_ = mustRunCLI(ctx, t, "instance", "new")
// Wait for both instances to register in SQLite
var out common.InstancesOutput
waitFor(t, defaultTimeout, func() (bool, string) {
out = listInstancesJSON(ctx, t)
if len(out.CoreInstances) >= 2 {
return true, ""
}
return false, fmt.Sprintf("waiting for 2 instances to appear in registry (have %d)", len(out.CoreInstances))
})
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) < 2 {
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
}
-399
View File
@@ -1,399 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"strings"
"testing"
)
// TestJSONLStreamingOutput tests streaming JSONL output with verbose flag
func TestJSONLStreamingOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Verbose flag should produce JSONL (streaming JSON lines)
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
// Split into lines
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 1 {
t.Fatal("verbose output should have content")
}
// Each non-empty line should be valid JSON
var foundSuccess bool
validJSONLines := 0
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var lineData map[string]interface{}
if err := json.Unmarshal([]byte(line), &lineData); err != nil {
t.Errorf("each JSONL line should be valid JSON: %v\nLine: %s", err, line)
continue
}
validJSONLines++
// Check line types (optional - implementation may vary)
if lineType, ok := lineData["type"].(string); ok {
if lineType == "response" {
foundSuccess = true
}
}
// Also check for status field as alternative
if status, ok := lineData["status"].(string); ok && status == "success" {
foundSuccess = true
}
}
// At least one line should be valid JSON
if validJSONLines == 0 {
t.Error("verbose output should have valid JSON lines")
}
// Should have final success (may or may not have debug - implementation dependent)
if !foundSuccess {
t.Log("Note: no explicit success response found - may be implementation dependent")
}
}
// TestNonStreamingJSONOutput tests that non-verbose JSON is single response
func TestNonStreamingJSONOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Without verbose, should be single JSON response
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json")
// Should be single valid JSON object
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("non-verbose output should be single JSON: %v", err)
}
// Should not have multiple lines
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) > 1 {
t.Error("non-verbose JSON should be single line")
}
// Should have response structure
if _, ok := result["status"]; !ok {
t.Error("response should have status field")
}
if _, ok := result["data"]; !ok {
t.Error("response should have data field")
}
}
// TestStreamingVerboseFormats tests verbose flag across all formats
func TestStreamingVerboseFormats(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
format string
checkFn func(string) bool
}{
{
"json-verbose",
"json",
func(out string) bool {
// Should have valid JSON content (may be single or multiple lines)
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) < 1 {
return false
}
// Each non-empty line should be valid JSON
validCount := 0
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
if json.Valid([]byte(line)) {
validCount++
}
}
return validCount > 0
},
},
{
"plain-verbose",
"plain",
func(out string) bool {
// Should not be JSON
return !json.Valid([]byte(out)) && out != ""
},
},
{
"rich-verbose",
"rich",
func(out string) bool {
// Should not be JSON
return !json.Valid([]byte(out)) && out != ""
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := mustRunCLI(ctx, t, "instance", "list", "--output-format", tt.format, "--verbose")
if !tt.checkFn(out) {
t.Errorf("%s verbose output failed validation", tt.name)
}
})
}
}
// TestProgressiveOutput tests that output arrives progressively (not buffered)
func TestProgressiveOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Verbose mode with multiple steps should show incremental progress
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
lines := strings.Split(strings.TrimSpace(out), "\n")
// Should have multiple progress updates (JSONL lines)
if len(lines) < 2 {
t.Error("progressive output should have multiple updates")
}
// Lines should arrive in sequence (chronological order can be verified if timestamps exist)
var timestamps []string
for _, line := range lines {
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err == nil {
if ts, ok := data["timestamp"].(string); ok {
timestamps = append(timestamps, ts)
}
}
}
// If we have timestamps, they should be in order
// (Not all lines may have timestamps, so this is optional verification)
if len(timestamps) > 1 {
for i := 1; i < len(timestamps); i++ {
if timestamps[i] < timestamps[i-1] {
t.Error("timestamps should be in chronological order")
}
}
}
}
// TestBufferedVsUnbufferedOutput tests output buffering behavior
func TestBufferedVsUnbufferedOutput(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Quick commands should complete with full output
out := mustRunCLI(ctx, t, "version", "--output-format", "json")
// Should be complete JSON
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Error("buffered output should be complete JSON")
}
// Verbose mode should also complete with full JSONL
verboseOut := mustRunCLI(ctx, t, "version", "--output-format", "json", "--verbose")
// All lines should be valid
lines := strings.Split(strings.TrimSpace(verboseOut), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
if !json.Valid([]byte(line)) {
t.Error("verbose output lines should all be valid JSON")
}
}
}
// TestRealTimeStatusUpdates tests that status updates appear in real-time
func TestRealTimeStatusUpdates(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Commands with multiple steps should show updates as they progress
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
// Parse JSONL
lines := strings.Split(strings.TrimSpace(out), "\n")
// Should have status/debug messages throughout execution
var statusUpdates []string
for _, line := range lines {
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err == nil {
// Check for new format: type="command" with status="debug"
if lineType, ok := data["type"].(string); ok {
if lineType == "command" {
if status, ok := data["status"].(string); ok && status == "debug" {
if msg, ok := data["message"].(string); ok {
statusUpdates = append(statusUpdates, msg)
}
}
}
}
}
}
// Should have at least one status/debug update
if len(statusUpdates) == 0 {
t.Error("real-time updates should include status/debug messages")
}
}
// TestStreamingOutputIntegrity tests that streaming doesn't corrupt data
func TestStreamingOutputIntegrity(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Create multiple instances with verbose output
for i := 0; i < 3; i++ {
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
// All JSONL lines should be valid
lines := strings.Split(strings.TrimSpace(out), "\n")
for j, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err != nil {
t.Errorf("instance %d, line %d: corrupted JSON: %v\nLine: %s", i, j, err, line)
}
}
}
}
// TestJSONLParsing tests that JSONL can be parsed line-by-line
func TestJSONLParsing(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
// Simulate line-by-line parsing (as a consumer would do)
lines := strings.Split(out, "\n")
var parsedLines int
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err != nil {
t.Errorf("failed to parse JSONL line: %v\nLine: %s", err, line)
continue
}
parsedLines++
// Each line should be self-contained
if _, ok := data["type"]; !ok {
t.Error("each JSONL line should have a type field")
}
}
if parsedLines < 2 {
t.Error("should have multiple parseable JSONL lines")
}
}
// TestNonStreamingCommands tests commands that don't support streaming
func TestNonStreamingCommands(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Simple informational commands should be non-streaming
commands := [][]string{
{"logs", "path", "--output-format", "json"},
{"version", "--short"},
}
for _, cmd := range commands {
out := mustRunCLI(ctx, t, cmd...)
// Should be single output (not multiple lines)
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) > 1 {
t.Errorf("command %v should have single-line output", cmd)
}
}
}
// TestVerboseFlagConsistency tests that verbose flag works consistently
func TestVerboseFlagConsistency(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test verbose flag with different commands
verboseCommands := [][]string{
{"instance", "list", "--output-format", "json", "--verbose"},
{"instance", "new", "--output-format", "json", "--verbose"},
{"config", "list", "--output-format", "json", "--verbose"},
}
for _, cmd := range verboseCommands {
t.Run(strings.Join(cmd[:len(cmd)-2], "-"), func(t *testing.T) {
out := mustRunCLI(ctx, t, cmd...)
// In JSON mode with verbose, should be JSONL
lines := strings.Split(strings.TrimSpace(out), "\n")
// Should have at least one line
if len(lines) == 0 {
t.Error("verbose output should not be empty")
}
// Each line should be valid JSON
for i, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
if !json.Valid([]byte(line)) {
t.Errorf("line %d should be valid JSON: %s", i, line)
}
}
})
}
}
// TestOutputOrdering tests that output maintains correct ordering
func TestOutputOrdering(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "instance", "new", "--output-format", "json", "--verbose")
lines := strings.Split(strings.TrimSpace(out), "\n")
// Find the final response line (should be last)
var responseLineIndex = -1
for i, line := range lines {
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err == nil {
if lineType, ok := data["type"].(string); ok && lineType == "response" {
responseLineIndex = i
}
}
}
// Response should be the last line
if responseLineIndex != -1 && responseLineIndex != len(lines)-1 {
t.Error("final response should be the last line in JSONL output")
}
}
-459
View File
@@ -1,459 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
)
// TestVerboseCrossCommandConsistency tests that verbose flag works consistently across all CLI commands
func TestVerboseCrossCommandConsistency(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Define command groups with their expected verbose behaviors
commandTests := []struct {
name string
args []string
description string
expectJSON bool // Whether JSON output should be valid
expectMultiLine bool // Whether verbose should produce multiple lines
}{
{
name: "version",
args: []string{"version"},
description: "Version command should support verbose output",
expectJSON: true,
expectMultiLine: false, // Version is a simple command
},
{
name: "instance-list",
args: []string{"instance", "list"},
description: "Instance list should show verbose details",
expectJSON: true,
expectMultiLine: false, // List commands are typically single response
},
{
name: "instance-new",
args: []string{"instance", "new"},
description: "Instance creation should show verbose progress",
expectJSON: true,
expectMultiLine: true, // Instance creation has multiple steps
},
{
name: "config-list",
args: []string{"config", "list"},
description: "Config list should show verbose configuration details",
expectJSON: true,
expectMultiLine: false, // Config list is typically single response
},
{
name: "logs-path",
args: []string{"logs", "path"},
description: "Logs path should work with verbose",
expectJSON: true,
expectMultiLine: false, // Simple informational command
},
}
for _, tt := range commandTests {
t.Run(tt.name, func(t *testing.T) {
// Test without verbose first for comparison
normalCmd := append(tt.args, "--output-format", "json")
normalOut, normalErr, normalExit := runCLI(ctx, t, normalCmd...)
// Test with verbose
verboseCmd := append(tt.args, "--output-format", "json", "--verbose")
verboseOut, verboseErr, verboseExit := runCLI(ctx, t, verboseCmd...)
// Both should succeed (or fail consistently)
if normalExit != verboseExit {
t.Errorf("%s: exit codes differ - normal: %d, verbose: %d", tt.description, normalExit, verboseExit)
}
// Skip further tests if command failed
if normalExit != 0 {
t.Logf("%s: command failed as expected (exit %d), skipping verbose validation", tt.description, normalExit)
return
}
// Validate JSON structure
if tt.expectJSON {
validateVerboseJSONOutput(t, tt.name, verboseOut, tt.expectMultiLine)
// Compare with normal output structure
compareOutputStructure(t, tt.name, normalOut, verboseOut)
}
// Verbose should generally produce more output
if len(verboseOut) < len(normalOut) && tt.expectMultiLine {
t.Errorf("%s: verbose output (%d chars) should be longer than normal output (%d chars)",
tt.description, len(verboseOut), len(normalOut))
}
// Verbose stderr should be same or more detailed
if verboseErr != normalErr && normalErr != "" {
t.Logf("%s: verbose stderr differs from normal - this may be expected", tt.description)
}
})
}
}
// validateVerboseJSONOutput validates that verbose JSON output follows expected structure
func validateVerboseJSONOutput(t *testing.T, testName, output string, expectMultiLine bool) {
t.Helper()
lines := strings.Split(strings.TrimSpace(output), "\n")
if expectMultiLine && len(lines) < 2 {
t.Errorf("%s: verbose JSON should have multiple lines, got %d", testName, len(lines))
}
validJSONLines := 0
hasStatusUpdate := false
hasResponse := false
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Each line should be valid JSON
var lineData map[string]interface{}
if err := json.Unmarshal([]byte(line), &lineData); err != nil {
t.Errorf("%s line %d: invalid JSON: %v\nLine: %s", testName, i, err, line)
continue
}
validJSONLines++
// Check for expected fields in verbose JSON
if lineType, ok := lineData["type"].(string); ok {
// New format: type="command" with status field
if lineType == "command" {
if status, ok := lineData["status"].(string); ok {
if status == "debug" {
hasStatusUpdate = true
} else if status == "success" || status == "error" {
hasResponse = true
}
}
}
// Legacy format support (if any)
switch lineType {
case "status", "debug", "progress":
hasStatusUpdate = true
case "response":
hasResponse = true
}
}
// All verbose lines should have timestamps if available
if timestamp, ok := lineData["timestamp"]; ok {
if _, ok := timestamp.(string); !ok {
t.Errorf("%s line %d: timestamp should be string, got %T", testName, i, timestamp)
}
}
}
if validJSONLines == 0 {
t.Errorf("%s: no valid JSON lines found in verbose output", testName)
}
// Multi-line verbose output should have either status updates or final response
if expectMultiLine && !hasStatusUpdate && !hasResponse {
t.Errorf("%s: verbose output should contain status updates or response", testName)
}
}
// compareOutputStructure compares normal vs verbose output structure
func compareOutputStructure(t *testing.T, testName, normalOut, verboseOut string) {
t.Helper()
// Parse normal output (should be single JSON)
var normalData map[string]interface{}
if err := json.Unmarshal([]byte(normalOut), &normalData); err != nil {
t.Errorf("%s: normal output should be valid JSON: %v", testName, err)
return
}
// Parse last line of verbose output (should be final response)
verboseLines := strings.Split(strings.TrimSpace(verboseOut), "\n")
lastLine := verboseLines[len(verboseLines)-1]
var verboseData map[string]interface{}
if err := json.Unmarshal([]byte(lastLine), &verboseData); err != nil {
t.Errorf("%s: verbose final line should be valid JSON: %v", testName, err)
return
}
// The final response structure should be similar
if normalStatus, ok := normalData["status"]; ok {
if verboseStatus, ok := verboseData["status"]; ok {
if normalStatus != verboseStatus {
t.Errorf("%s: status differs between normal (%v) and verbose (%v)",
testName, normalStatus, verboseStatus)
}
}
}
// Data structure should be preserved
if normalDataField, ok := normalData["data"]; ok {
if verboseDataField, ok := verboseData["data"]; ok {
// Deep comparison of data structures
if !reflect.DeepEqual(normalDataField, verboseDataField) {
t.Logf("%s: data structures differ between normal and verbose - this may be expected due to additional verbose fields", testName)
}
}
}
}
// TestVerboseFlagValidation tests that verbose flag is properly validated
func TestVerboseFlagValidation(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
tests := []struct {
name string
args []string
expectError bool
}{
{
name: "verbose-with-json",
args: []string{"version", "--verbose", "--output-format", "json"},
expectError: false,
},
{
name: "verbose-with-plain",
args: []string{"version", "--verbose", "--output-format", "plain"},
expectError: false,
},
{
name: "verbose-with-rich",
args: []string{"version", "--verbose", "--output-format", "rich"},
expectError: false,
},
{
name: "verbose-only",
args: []string{"version", "--verbose"},
expectError: false, // Should use default format
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, exit := runCLI(ctx, t, tt.args...)
if tt.expectError && exit == 0 {
t.Errorf("expected command to fail but it succeeded")
}
if !tt.expectError && exit != 0 {
t.Errorf("expected command to succeed but it failed with exit %d", exit)
}
})
}
}
// TestVerboseOutputIntegrity tests that verbose output maintains data integrity
func TestVerboseOutputIntegrity(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Run the same command multiple times to ensure consistent verbose output
const iterations = 5
var outputs []string
for i := 0; i < iterations; i++ {
out := mustRunCLI(ctx, t, "version", "--output-format", "json", "--verbose")
outputs = append(outputs, out)
}
// All outputs should have similar structure
for i := 1; i < len(outputs); i++ {
lines1 := strings.Split(strings.TrimSpace(outputs[0]), "\n")
lines2 := strings.Split(strings.TrimSpace(outputs[i]), "\n")
if len(lines1) != len(lines2) {
t.Errorf("iteration %d: line count differs (%d vs %d)", i, len(lines1), len(lines2))
}
// Each corresponding line should have same structure (though values may differ)
for j := 0; j < len(lines1) && j < len(lines2); j++ {
var data1, data2 map[string]interface{}
if err := json.Unmarshal([]byte(lines1[j]), &data1); err != nil {
continue // Skip non-JSON lines
}
if err := json.Unmarshal([]byte(lines2[j]), &data2); err != nil {
continue
}
// Should have same keys
if len(data1) != len(data2) {
t.Errorf("iteration %d, line %d: field count differs", i, j)
}
for key := range data1 {
if _, ok := data2[key]; !ok {
t.Errorf("iteration %d, line %d: missing key %s", i, j, key)
}
}
}
}
}
// TestVerboseErrorHandling tests that verbose mode handles errors gracefully
func TestVerboseErrorHandling(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Test commands that should fail
errorTests := []struct {
name string
args []string
}{
{
name: "invalid-instance",
args: []string{"instance", "kill", "nonexistent:9999", "--verbose", "--output-format", "json"},
},
{
name: "invalid-flag",
args: []string{"version", "--invalid-flag", "--verbose", "--output-format", "json"},
},
}
for _, tt := range errorTests {
t.Run(tt.name, func(t *testing.T) {
out, errOut, exit := runCLI(ctx, t, tt.args...)
// Should fail
if exit == 0 {
t.Errorf("expected command to fail but it succeeded")
}
// Even in error cases, if JSON output is requested and produced, it should be valid
if strings.Contains(strings.Join(tt.args, " "), "--output-format") &&
strings.Contains(strings.Join(tt.args, " "), "json") &&
out != "" {
// Try to parse as JSON
lines := strings.Split(strings.TrimSpace(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err != nil {
t.Errorf("error output should be valid JSON: %v\nLine: %s", err, line)
}
}
}
// Error output should be present
if errOut == "" && out == "" {
t.Error("expected some error output")
}
})
}
}
// TestVerboseTimestampConsistency tests that timestamps in verbose output are reasonable
func TestVerboseTimestampConsistency(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
out := mustRunCLI(ctx, t, "version", "--output-format", "json", "--verbose")
lines := strings.Split(strings.TrimSpace(out), "\n")
var timestamps []time.Time
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err != nil {
continue
}
if tsStr, ok := data["timestamp"].(string); ok {
// Try common timestamp formats
formats := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02T15:04:05.000Z",
"2006-01-02T15:04:05Z",
}
var ts time.Time
var parseErr error
for _, format := range formats {
ts, parseErr = time.Parse(format, tsStr)
if parseErr == nil {
break
}
}
if parseErr != nil {
t.Errorf("unable to parse timestamp %s: %v", tsStr, parseErr)
} else {
timestamps = append(timestamps, ts)
}
}
}
// Timestamps should be in chronological order
for i := 1; i < len(timestamps); i++ {
if timestamps[i].Before(timestamps[i-1]) {
t.Errorf("timestamps not in chronological order: %v before %v",
timestamps[i], timestamps[i-1])
}
}
// All timestamps should be recent (within last minute)
now := time.Now()
for i, ts := range timestamps {
if now.Sub(ts) > time.Minute {
t.Errorf("timestamp %d too old: %v (now: %v)", i, ts, now)
}
}
}
// TestVerboseModeMemoryUsage tests that verbose mode doesn't cause memory issues
func TestVerboseModeMemoryUsage(t *testing.T) {
ctx := context.Background()
setTempClineDir(t)
// Run verbose command multiple times to check for memory leaks
const iterations = 10
for i := 0; i < iterations; i++ {
out := mustRunCLI(ctx, t, "version", "--output-format", "json", "--verbose")
// Output length should be reasonable (not growing unboundedly)
if len(out) > 100*1024 { // 100KB threshold for version command
t.Errorf("iteration %d: verbose output unusually large (%d bytes)", i, len(out))
}
// Should still be valid JSON
lines := strings.Split(strings.TrimSpace(out), "\n")
for j, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(line), &data); err != nil {
t.Errorf("iteration %d, line %d: invalid JSON: %v", i, j, err)
}
}
}
}
-146
View File
@@ -1,146 +0,0 @@
# Verbose Test Suite Documentation
## Overview
This document describes the comprehensive verbose test suite (`verbose_comprehensive_test.go`) that validates verbose functionality across the Cline CLI application.
## Test Coverage
### 1. TestVerboseCrossCommandConsistency
**Purpose**: Ensures verbose flag works consistently across all CLI commands.
**Commands Tested**:
- `version` - Simple informational command
- `instance list` - List existing instances
- `instance new` - Create new instance (multi-step process)
- `config list` - List configuration settings
- `logs path` - Show logs directory path
**Validations**:
- Exit codes are consistent between normal and verbose modes
- JSON output structure is valid
- Multi-line output for complex operations (instance creation)
- Single-line output for simple operations (version, list commands)
- Data structure preservation between normal and verbose modes
### 2. TestVerboseFlagValidation
**Purpose**: Validates that verbose flag is properly accepted across output formats.
**Test Cases**:
- `--verbose --output-format json`
- `--verbose --output-format plain`
- `--verbose --output-format rich`
- `--verbose` (using default format)
**Validations**:
- All combinations should succeed (exit code 0)
- No unexpected validation errors
### 3. TestVerboseOutputIntegrity
**Purpose**: Ensures verbose output maintains consistent structure across multiple runs.
**Approach**:
- Runs same command 5 times with verbose flag
- Compares output structure consistency
- Validates JSON schema consistency
**Validations**:
- Line count consistency
- JSON field consistency
- No data corruption between runs
### 4. TestVerbosePerformanceImpact
**Purpose**: Measures performance impact of verbose mode.
**Metrics**:
- Execution time comparison (normal vs verbose)
- Acceptable threshold: verbose ≤ 3x slower than normal
**Results**:
- Typical impact: ~1.1-1.3x slower (well within acceptable range)
- No significant performance degradation
### 5. TestVerboseErrorHandling
**Purpose**: Validates error handling in verbose mode.
**Error Scenarios**:
- Invalid commands (`nonexistent-command`)
- Invalid flags (`--invalid-flag`)
**Validations**:
- Proper exit codes for error conditions
- Valid JSON structure even in error cases
- Appropriate error output generation
### 6. TestVerboseTimestampConsistency
**Purpose**: Validates timestamp handling in verbose output.
**Checks**:
- Timestamp format parsing (RFC3339, RFC3339Nano, etc.)
- Chronological ordering of timestamps
- Timestamp recency (within last minute)
**Validations**:
- Parseable timestamp formats
- Logical temporal ordering
- Reasonable timestamp values
### 7. TestVerboseModeMemoryUsage
**Purpose**: Ensures verbose mode doesn't cause memory issues.
**Approach**:
- Runs verbose command 10 times in sequence
- Monitors output size consistency
- Validates JSON structure preservation
**Thresholds**:
- Output size limit: 100KB for version command
- No unbounded growth between iterations
## Test Results Summary
**All tests PASS** - The verbose functionality is working correctly across all tested scenarios.
### Key Findings:
1. **Consistency**: Verbose flag works uniformly across different command types
2. **Performance**: Minimal performance impact (~30% overhead, well within acceptable limits)
3. **Reliability**: Output structure is consistent across multiple runs
4. **Error Handling**: Graceful degradation in error scenarios
5. **Memory Efficiency**: No memory leaks or unbounded growth detected
### Command Behavior Patterns:
- **Simple Commands** (version, list, logs path): Single-line JSON response even in verbose mode
- **Complex Commands** (instance new): Multi-line JSONL streaming with progress updates
- **Error Cases**: Proper error reporting with valid JSON structure when applicable
## Integration with Existing Tests
This test suite complements the existing streaming tests (`streaming_test.go`) by:
- Providing broader command coverage
- Adding performance and memory validation
- Testing error scenarios more thoroughly
- Validating cross-format consistency
## Maintenance Notes
- Test expectations are calibrated to actual CLI behavior (some commands produce single-line even in verbose mode)
- Performance thresholds may need adjustment for slower systems
- New CLI commands should be added to the cross-command consistency test
- Timestamp format support should be updated if new formats are introduced
## Usage
Run the verbose test suite:
```bash
go test ./e2e -run TestVerbose -v
```
Run specific verbose tests:
```bash
go test ./e2e -run TestVerboseCrossCommandConsistency -v
go test ./e2e -run TestVerbosePerformanceImpact -v
```
The tests automatically handle temporary directory setup and cleanup, making them safe to run in any environment.
-6
View File
@@ -2,7 +2,6 @@ package cli
import (
"github.com/cline/cli/pkg/cli/auth"
"github.com/cline/cli/pkg/cli/global"
"github.com/spf13/cobra"
)
@@ -30,11 +29,6 @@ Quick Setup Mode:
Supported providers: openai-native, openai, anthropic, gemini, openrouter, xai, cerebras, ollama
Note: Bedrock provider requires interactive setup due to complex auth fields`,
RunE: func(cmd *cobra.Command, args []string) error {
// Check for JSON output mode - not supported for interactive commands
// Per the plan: Interactive commands output PLAIN TEXT errors, not JSON
if err := global.Config.MustNotBeJSON("auth"); err != nil {
return err
}
return auth.RunAuthFlow(cmd.Context(), args)
},
}
+3 -2
View File
@@ -29,8 +29,9 @@ func HandleClineAuth(ctx context.Context) error {
}
fmt.Println()
verboseLog("✓ You are signed in!")
// Configure default Cline model after successful authentication
if err := configureDefaultClineModel(ctx); err != nil {
@@ -281,4 +282,4 @@ func HandleSelectOrganization(ctx context.Context) error {
}
return HandleAuthMenuNoArgs(ctx)
}
}
+1
View File
@@ -139,6 +139,7 @@ func validateBaseURL(baseURL string, providerEnum cline.ApiProvider) error {
return nil
}
// validateQuickSetupProvider validates the provider ID and returns the enum value
// Returns error if provider is invalid or not supported for quick setup
func validateQuickSetupProvider(providerID string) (cline.ApiProvider, error) {
+1
View File
@@ -40,6 +40,7 @@ func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModel
return result
}
// FetchOpenAiModels fetches available OpenAI models from Cline Core
// Takes the API key and returns a list of model IDs
func FetchOpenAiModels(ctx context.Context, manager *task.Manager, baseURL, apiKey string) ([]string, error) {
+2 -1
View File
@@ -132,7 +132,7 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
// Determine readiness: OCA uses auth state presence; others need creds and model
if provider == cline.ApiProvider_OCA {
state, _ := GetLatestOCAState(context.Background(), 2*time.Second)
state, _ := GetLatestOCAState(context.Background(), 2 *time.Second)
if state == nil || state.User == nil {
continue
}
@@ -498,6 +498,7 @@ func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([
}
}
verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders))
for _, p := range configuredProviders {
verboseLog("[DEBUG] - %s", GetProviderDisplayName(p))
+4 -3
View File
@@ -517,7 +517,7 @@ func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID s
ModelInfo: modelInfo,
}
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, true)
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, false)
}
// SwitchToBYOProvider switches to a BYO provider that's already configured.
@@ -585,11 +585,11 @@ func getProviderModelIDFromState(stateData map[string]interface{}, provider clin
return ""
}
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
if provider == cline.ApiProvider_OCA {
if state, _ := GetLatestOCAState(context.TODO(), 2*time.Second); state != nil && state.User != nil {
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
// Return a sentinel non-empty string so upstream checks pass.
return "OCA_AUTH_VERIFIED"
}
@@ -748,6 +748,7 @@ func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
}
func signOutOca(ctx context.Context) error {
client, err := global.GetDefaultClient(ctx)
if err != nil {
+14 -14
View File
@@ -15,23 +15,23 @@ import (
// BedrockConfig holds all AWS Bedrock-specific configuration fields
type BedrockConfig struct {
// Profile authentication fields
UseProfile bool // Always true for successful config
Profile string // Optional: AWS profile name (empty = default)
Region string // Required: AWS region
Endpoint string // Optional: Custom VPC endpoint URL
UseProfile bool // Always true for successful config
Profile string // Optional: AWS profile name (empty = default)
Region string // Required: AWS region
Endpoint string // Optional: Custom VPC endpoint URL
// Optional features
UseCrossRegionInference bool // Optional: Enable cross-region inference
UseGlobalInference bool // Optional: Use global inference endpoint
UsePromptCache bool // Optional: Enable prompt caching
UseCrossRegionInference bool // Optional: Enable cross-region inference
UseGlobalInference bool // Optional: Use global inference endpoint
UsePromptCache bool // Optional: Enable prompt caching
// Authentication method (always "profile")
Authentication string // Always set to "profile"
Authentication string // Always set to "profile"
// Legacy fields (no longer used in profile-only flow)
AccessKey string // No longer used
SecretKey string // No longer used
SessionToken string // No longer used
AccessKey string // No longer used
SecretKey string // No longer used
SessionToken string // No longer used
}
// PromptForBedrockConfig displays a profile-first authentication form for Bedrock configuration
+9 -9
View File
@@ -107,14 +107,14 @@ type ocaAuthStream interface {
// OcaAuthStatusListener manages subscription to OCA auth status updates
type OcaAuthStatusListener struct {
stream ocaAuthStream
updatesCh chan *cline.OcaAuthState
errCh chan error
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
lastState *cline.OcaAuthState
firstEventCh chan struct{}
stream ocaAuthStream
updatesCh chan *cline.OcaAuthState
errCh chan error
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
lastState *cline.OcaAuthState
firstEventCh chan struct{}
firstEventOnce sync.Once
}
@@ -295,7 +295,7 @@ func IsOCAAuthenticated(ctx context.Context) bool {
return l.IsAuthenticated()
}
// LatestState returns the last received OCA auth state (may be nil)
// LatestState returns the last received OCA auth state (may be nil)
func (l *OcaAuthStatusListener) LatestState() *cline.OcaAuthState {
l.mu.RLock()
defer l.mu.RUnlock()
-45
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
)
@@ -61,26 +60,6 @@ func (m *Manager) UpdateSettings(ctx context.Context, settings *cline.Settings,
return fmt.Errorf("failed to update settings: %w", err)
}
// Extract updated field names
var updated []string
if settings != nil {
// Use reflection or manual extraction based on what was set
// For now, we'll indicate that settings were updated
updated = append(updated, "settings")
}
if secrets != nil {
updated = append(updated, "secrets")
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"updated": updated,
"instance": m.clientAddress,
}
return output.OutputJSONSuccess("config set", data)
}
fmt.Println("Settings updated successfully")
fmt.Printf("Instance: %s\n", m.clientAddress)
return nil
@@ -134,21 +113,6 @@ func (m *Manager) ListSettings(ctx context.Context) error {
"autoApprovalSettings",
}
// Check for JSON output mode
if global.Config.JsonFormat() {
// Filter to settings fields
settings := make(map[string]interface{})
for _, field := range settingsFields {
if value, ok := stateData[field]; ok {
settings[field] = value
}
}
data := map[string]interface{}{
"settings": settings,
}
return output.OutputJSONSuccess("config list", data)
}
// Render each field using the renderer
for _, field := range settingsFields {
if value, ok := stateData[field]; ok {
@@ -179,15 +143,6 @@ func (m *Manager) GetSetting(ctx context.Context, key string) error {
return fmt.Errorf("setting '%s' not found", key)
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"key": key,
"value": value,
}
return output.OutputJSONSuccess("config get", data)
}
// Render the value
if len(parts) == 1 {
// Top-level field: use RenderField for nice formatting
+4 -4
View File
@@ -30,14 +30,14 @@ func isSensitiveField(fieldName string) bool {
if fieldName == "" {
return false
}
lowerName := strings.ToLower(fieldName)
for _, keyword := range sensitiveKeywords {
if strings.Contains(lowerName, keyword) {
return true
}
}
return false
}
@@ -47,14 +47,14 @@ func formatValue(val interface{}, fieldName string, censor bool) string {
if str, ok := val.(string); ok && str == "" {
return "''"
}
if censor && isSensitiveField(fieldName) {
valStr := fmt.Sprintf("%v", val)
if valStr != "" && valStr != "''" {
return "********"
}
}
return fmt.Sprintf("%v", val)
}
+6 -6
View File
@@ -12,11 +12,11 @@ import (
// BannerInfo contains information to display in the session banner
type BannerInfo struct {
Version string
Provider string
ModelID string
Workdir string
Mode string
Version string
Provider string
ModelID string
Workdir string
Mode string
}
// RenderSessionBanner renders a nice banner showing version, model, and workspace info
@@ -187,7 +187,7 @@ func shortenModelID(modelID string) string {
if len(modelID) > 9 {
suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
if suffix[0] == '-' &&
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
// Verify all remaining chars are digits
allDigits := true
for _, c := range suffix[1:] {
+10 -5
View File
@@ -24,12 +24,13 @@ type MarkdownRenderer struct {
// word wrap, the indentation looks weird so you have to turn it off
// and everything will be right next to the left margin
// but if you DO use glamours word wrap, it also means if you resize the terminal,
// it will scuff everything. but given that this is the case for the input anyway,
// i figure we just make things as beautiful as possible
// it will scuff everything. but given that this is the case for the input anyway,
// i figure we just make things as beautiful as possible
// and if you resize the terminal, you'll learn real quick.
// anyway, you can set this to true or false to experiment
const USETERMINALWORDWRAP = true
// seems like a reliable way to check for terminals
// for now i'm keeping everything as auto
// eventually we can define a custom glamour style for ghostty / iterm
@@ -63,6 +64,9 @@ func glamourStyleJSON(terminalWrap bool) string {
return fmt.Sprintf(tmpl, "2")
}
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
var wordWrap int
if USETERMINALWORDWRAP {
@@ -74,9 +78,9 @@ func NewMarkdownRenderer() (*MarkdownRenderer, error) {
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins
glamour.WithWordWrap(wordWrap),
glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins
glamour.WithWordWrap(wordWrap),
glamour.WithPreservedNewLines(),
)
if err != nil {
@@ -118,6 +122,7 @@ func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) {
return &MarkdownRenderer{renderer: r, width: width}, nil
}
// NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width.
// Falls back to 120 if terminal width cannot be determined.
func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) {
+22 -59
View File
@@ -82,28 +82,29 @@ func formatNumber(n int) string {
// formatUsageInfo formats token usage information (extracted from RenderAPI)
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
parts := make([]string, 0, 4)
parts := make([]string, 0, 4)
if tokensIn != 0 {
parts = append(parts, fmt.Sprintf("↑ %s", formatNumber(tokensIn)))
}
if tokensOut != 0 {
parts = append(parts, fmt.Sprintf("↓ %s", formatNumber(tokensOut)))
}
if cacheReads != 0 {
parts = append(parts, fmt.Sprintf("→ %s", formatNumber(cacheReads)))
}
if cacheWrites != 0 {
parts = append(parts, fmt.Sprintf("← %s", formatNumber(cacheWrites)))
}
if tokensIn != 0 {
parts = append(parts, fmt.Sprintf("↑ %s", formatNumber(tokensIn)))
}
if tokensOut != 0 {
parts = append(parts, fmt.Sprintf("↓ %s", formatNumber(tokensOut)))
}
if cacheReads != 0 {
parts = append(parts, fmt.Sprintf("→ %s", formatNumber(cacheReads)))
}
if cacheWrites != 0 {
parts = append(parts, fmt.Sprintf("← %s", formatNumber(cacheWrites)))
}
if len(parts) == 0 {
return fmt.Sprintf("$%.4f", cost)
}
if len(parts) == 0 {
return fmt.Sprintf("$%.4f", cost)
}
return fmt.Sprintf("%s $%.4f", strings.Join(parts, " "), cost)
return fmt.Sprintf("%s $%.4f", strings.Join(parts, " "), cost)
}
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
if apiInfo.Cost >= 0 {
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
@@ -147,39 +148,6 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
recentTasks := tasks[startIndex:]
// Check for JSON output mode
if global.Config.JsonFormat() {
// Build JSON structure
taskList := make([]map[string]interface{}, len(recentTasks))
for i, taskItem := range recentTasks {
description := taskItem.Task
if len(description) > 1000 {
description = description[:1000] + "..."
}
taskList[i] = map[string]interface{}{
"id": taskItem.Id,
"task": description,
"ts": taskItem.Ts,
"isFavorited": taskItem.IsFavorited,
"size": taskItem.Size,
"totalCost": taskItem.TotalCost,
"tokensIn": taskItem.TokensIn,
"tokensOut": taskItem.TokensOut,
"cacheWrites": taskItem.CacheWrites,
"cacheReads": taskItem.CacheReads,
}
}
data := map[string]interface{}{
"tasks": taskList,
"totalCount": len(tasks),
"shown": len(recentTasks),
}
return output.OutputJSONSuccess("task list", data)
}
// Rich/plain output
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
for i, taskItem := range recentTasks {
@@ -206,13 +174,6 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
if global.Config.Verbose {
message := fmt.Sprintf(format, args...)
// In JSON mode, output as JSONL immediately
if global.Config.JsonFormat() {
return output.OutputStatusMessage("debug", message, nil)
}
// In plain/rich mode, output as text
r.typewriter.PrintMessageLine("[DEBUG]", message)
}
return nil
@@ -270,8 +231,10 @@ func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
// Falls back to plaintext if markdown rendering is unavailable or fails
// Respects output format - skips rendering in plain mode or non-TTY contexts
func (r *Renderer) RenderMarkdown(markdown string) string {
// Skip markdown rendering in plain mode
if global.Config.PlainFormat() {
// Skip markdown rendering if:
// 1. Output format is explicitly "plain"
// 2. Not in a TTY (piped output, file redirect, CI, etc.)
if r.outputFormat == "plain" || !isTTY() {
return markdown
}
+9 -7
View File
@@ -58,10 +58,11 @@ func (ss *StreamingSegment) AppendText(text string) {
// Replace buffer with FULL text - msg.Text contains complete accumulated content
ss.buffer.Reset()
ss.buffer.WriteString(text)
// No rendering during streaming - we'll render once on Freeze()
}
func (ss *StreamingSegment) Freeze() {
ss.mu.Lock()
defer ss.mu.Unlock()
@@ -72,7 +73,7 @@ func (ss *StreamingSegment) Freeze() {
ss.frozen = true
currentBuffer := ss.buffer.String()
// Render and print the final markdown
ss.renderFinal(currentBuffer)
}
@@ -144,21 +145,22 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
}
}
// generateRichHeader generates a contextual header for the segment
func (ss *StreamingSegment) generateRichHeader() string {
switch ss.sayType {
case string(types.SayTypeReasoning):
return "### Cline is thinking\n"
case string(types.SayTypeText):
return "### Cline responds\n"
case string(types.SayTypeCompletionResult):
return "### Task completed\n"
case string(types.SayTypeTool):
return ss.generateToolHeader()
case "ask":
// Check the specific ask type
if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
@@ -191,7 +193,7 @@ func (ss *StreamingSegment) generateRichHeader() string {
// For other ask types, show generic message
return fmt.Sprintf("### Cline is asking (%s)\n", ss.msg.Ask)
default:
return fmt.Sprintf("### %s\n", ss.prefix)
}
+2 -2
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"strings"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/types"
)
@@ -351,7 +350,8 @@ func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) strin
// renderMarkdown renders markdown if not in plain mode and in a TTY
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
if global.Config.PlainFormat() {
// Skip markdown rendering if plain mode or not in TTY
if tr.outputFormat == "plain" || !isTTY() {
return markdown
}
+17 -17
View File
@@ -69,7 +69,7 @@ func (p *ToolResultParser) ParseListFiles(content, path string) string {
}
lines := strings.Split(strings.TrimSpace(content), "\n")
// Check for truncation message
var truncationMsg string
lastLine := lines[len(lines)-1]
@@ -79,7 +79,7 @@ func (p *ToolResultParser) ParseListFiles(content, path string) string {
}
totalFiles := len(lines)
var result strings.Builder
result.WriteString(fmt.Sprintf("*%d %s*\n\n", totalFiles, p.pluralize(totalFiles, "file", "files")))
@@ -132,7 +132,7 @@ func (p *ToolResultParser) ParseSearchFiles(content string) string {
// Extract result count from first line
firstLine := lines[0]
var result strings.Builder
result.WriteString(fmt.Sprintf("*%s*\n\n", firstLine))
@@ -146,7 +146,7 @@ func (p *ToolResultParser) ParseSearchFiles(content string) string {
for i := 1; i < len(lines) && filesShown < maxFiles && matchesShown < maxMatches; i++ {
line := lines[i]
if line == "" {
continue
}
@@ -158,7 +158,7 @@ func (p *ToolResultParser) ParseSearchFiles(content string) string {
result.WriteString(p.formatFileMatches(currentFile, fileResults))
filesShown++
}
currentFile = line
fileResults = []string{}
} else if currentFile != "" {
@@ -186,14 +186,14 @@ func (p *ToolResultParser) ParseSearchFiles(content string) string {
// formatFileMatches formats matches for a single file
func (p *ToolResultParser) formatFileMatches(file string, matches []string) string {
var result strings.Builder
// Parse file path and extension for syntax highlighting
ext := filepath.Ext(file)
lang := p.detectLanguage(ext)
result.WriteString(fmt.Sprintf("**%s** (%d %s)\n", file, len(matches), p.pluralize(len(matches), "match", "matches")))
result.WriteString(fmt.Sprintf("```%s\n", lang))
maxMatches := 5
for i, match := range matches {
if i >= maxMatches {
@@ -203,9 +203,9 @@ func (p *ToolResultParser) formatFileMatches(file string, matches []string) stri
result.WriteString(match)
result.WriteString("\n")
}
result.WriteString("```\n\n")
return result.String()
}
@@ -226,9 +226,9 @@ func (p *ToolResultParser) ParseWebFetch(content, url string) string {
}
lines := strings.Split(content, "\n")
var result strings.Builder
// Try to extract title
var title string
for _, line := range lines {
@@ -245,26 +245,26 @@ func (p *ToolResultParser) ParseWebFetch(content, url string) string {
// Show preview of content
result.WriteString("**Preview:**\n")
charCount := 0
maxChars := 500
previewLines := []string{}
for _, line := range lines {
// Skip markdown headers
if strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if charCount+len(trimmed) > maxChars {
break
}
previewLines = append(previewLines, trimmed)
charCount += len(trimmed)
}
+94 -82
View File
@@ -10,7 +10,6 @@ import (
"syscall"
"time"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
)
@@ -36,34 +35,6 @@ func (c *ClineClients) Initialize(ctx context.Context) error {
return nil
}
// VerboseLog outputs a verbose message in the appropriate format
func VerboseLog(command, message string) {
if !Config.Verbose {
return
}
if Config.JsonFormat() {
output.OutputCommandStatus(command, "debug", message, nil)
} else {
fmt.Println(message)
}
}
// VerboseLogf outputs a formatted verbose message
func VerboseLogf(command, format string, args ...interface{}) {
VerboseLog(command, fmt.Sprintf(format, args...))
}
// For backward compatibility within this package
func verboseLog(message string) {
// Use generic "cline" command for internal calls
VerboseLog("cline", message)
}
func verboseLogf(format string, args ...interface{}) {
VerboseLogf("cline", format, args...)
}
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
// Find available ports
@@ -72,7 +43,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
return nil, fmt.Errorf("failed to find available ports: %w", err)
}
verboseLogf("Starting new Cline instance on ports %d (core) and %d (host bridge)", corePort, hostPort)
if Config.Verbose {
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
}
// Start cline-host first
hostCmd, err := startClineHost(hostPort, corePort)
@@ -91,7 +64,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
}
fullAddress := fmt.Sprintf("localhost:%d", corePort)
verboseLog("Waiting for services to start and self-register in SQLite...")
if Config.Verbose {
fmt.Println("Waiting for services to start and self-register in SQLite...")
}
// Use RetryOperation to wait for instance to be ready
var instance *common.CoreInstanceInfo
@@ -125,16 +100,20 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
return nil, fmt.Errorf("failed to start instance: %w", err)
}
verboseLog("Services started and registered successfully!")
verboseLogf(" Address: %s", instance.Address)
verboseLogf(" Core Port: %d", instance.CorePort())
verboseLogf(" Host Bridge Port: %d", instance.HostPort())
verboseLogf(" Process PID: %d", coreCmd.Process.Pid)
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
}
// If this is the first instance, set it as default
instances := c.registry.ListInstances()
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
verboseLogf("Warning: Failed to set default instance: %v", err)
if Config.Verbose {
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
}
}
return instance, nil
@@ -151,7 +130,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
}
verboseLogf("Starting new Cline instance on ports %d (core) and %d (host bridge)", corePort, hostPort)
if Config.Verbose {
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
}
// Start cline-host first
hostCmd, err := startClineHost(hostPort, corePort)
@@ -170,7 +151,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
}
fullAddress := fmt.Sprintf("localhost:%d", corePort)
verboseLog("Waiting for services to start and self-register in SQLite...")
if Config.Verbose {
fmt.Println("Waiting for services to start and self-register in SQLite...")
}
// Use RetryOperation to wait for instance to be ready
var instance *common.CoreInstanceInfo
@@ -204,16 +187,20 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
}
verboseLog("Services started and registered successfully!")
verboseLogf(" Address: %s", instance.Address)
verboseLogf(" Core Port: %d", instance.CorePort())
verboseLogf(" Host Bridge Port: %d", instance.HostPort())
verboseLogf(" Process PID: %d", coreCmd.Process.Pid)
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
}
// If this is the first instance, set it as default
instances := c.registry.ListInstances()
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
verboseLogf("Warning: Failed to set default instance: %v", err)
if Config.Verbose {
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
}
}
return instance, nil
@@ -233,15 +220,8 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
}
// Check if instance already exists at this address
// HasInstanceAtAddress may fail if database doesn't exist yet (fresh CLINE_DIR)
// In that case, we should proceed to start an instance
hasInstance := c.registry.HasInstanceAtAddress(normalized)
if hasInstance {
// Instance exists and is registered, verify it's healthy
if common.IsInstanceHealthy(ctx, normalized) {
return nil
}
// Instance is registered but not healthy, proceed to start a new one
if c.registry.HasInstanceAtAddress(normalized) {
return nil
}
// Parse host:port
@@ -263,7 +243,9 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
}
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
verboseLogf("Starting cline-host on port %d", hostPort)
if Config.Verbose {
fmt.Printf("Starting cline-host on port %d\n", hostPort)
}
// Get the directory where the cline binary is located
execPath, err := os.Executable()
@@ -307,8 +289,10 @@ func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
verboseLogf("Started cline-host (PID: %d)", cmd.Process.Pid)
verboseLogf("Logging cline-host output to: %s", logFilePath)
if Config.Verbose {
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
}
return cmd, nil
}
@@ -320,7 +304,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
return fmt.Errorf("instance %s not found in registry", address)
}
verboseLogf("Killing instance: %s", address)
if Config.Verbose {
fmt.Printf("Killing instance: %s\n", address)
}
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
@@ -334,7 +320,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
}
pid := int(processInfo.ProcessId)
verboseLogf("Terminating process PID %d...", pid)
if Config.Verbose {
fmt.Printf("Terminating process PID %d...\n", pid)
}
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
@@ -342,11 +330,15 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
}
// Wait for the instance to remove itself from registry
verboseLog("Waiting for instance to clean up registry entry...")
if Config.Verbose {
fmt.Printf("Waiting for instance to clean up registry entry...\n")
}
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second)
if !registry.HasInstanceAtAddress(address) {
verboseLogf("Instance %s successfully killed and removed from registry.", address)
if Config.Verbose {
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
}
// Update default instance if needed
instances, err := registry.ListInstancesCleaned(ctx)
@@ -356,7 +348,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
verboseLogf("Updated default instance to: %s", instances[0].Address)
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
}
}
}
}
@@ -370,7 +364,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
}
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
verboseLogf("Starting cline-core on port %d (with hostbridge on %d)", corePort, hostPort)
if Config.Verbose {
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
}
// Get the executable path and resolve symlinks (for npm global installs)
execPath, err := os.Executable()
@@ -385,20 +381,24 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
if err != nil {
// If we can't resolve symlinks, fall back to the original path
realPath = execPath
verboseLogf("Warning: Could not resolve symlinks for %s: %v", execPath, err)
if Config.Verbose {
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
}
}
binDir := path.Dir(realPath)
installDir := path.Dir(binDir)
clineCorePath := path.Join(installDir, "cline-core.js")
verboseLogf("Executable path: %s", execPath)
if realPath != execPath {
verboseLogf("Real path (after resolving symlinks): %s", realPath)
if Config.Verbose {
fmt.Printf("Executable path: %s\n", execPath)
if realPath != execPath {
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
}
fmt.Printf("Bin directory: %s\n", binDir)
fmt.Printf("Install directory: %s\n", installDir)
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
}
verboseLogf("Bin directory: %s", binDir)
verboseLogf("Install directory: %s", installDir)
verboseLogf("Looking for cline-core.js at: %s", clineCorePath)
// Check if cline-core.js exists at the primary location
var finalClineCorePath string
@@ -408,20 +408,26 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
// This handles the case where we're running from cli/bin/cline
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
verboseLogf("Primary location not found, trying development path: %s", devClineCorePath)
if Config.Verbose {
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
}
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
}
finalClineCorePath = devClineCorePath
finalInstallDir = devInstallDir
verboseLogf("Using development mode: cline-core.js found at %s", finalClineCorePath)
if Config.Verbose {
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
}
} else {
finalClineCorePath = clineCorePath
finalInstallDir = installDir
verboseLogf("Using production mode: cline-core.js found at %s", finalClineCorePath)
if Config.Verbose {
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
}
}
// Create logs directory in ~/.cline/logs
@@ -445,7 +451,9 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
"--config", Config.ConfigPath}
verboseLog("Using system node")
if Config.Verbose {
fmt.Printf("Using system node\n")
}
cmd := exec.Command("node", args...)
@@ -467,7 +475,7 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
realNodeModules := path.Join(finalInstallDir, "node_modules")
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
env = append(env,
fmt.Sprintf("NODE_PATH=%s", nodePath),
"GRPC_TRACE=all",
@@ -475,15 +483,19 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
"NODE_ENV=development",
)
cmd.Env = env
verboseLogf("NODE_PATH set to: %s", nodePath)
if Config.Verbose {
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
verboseLogf("Started cline-core (PID: %d)", cmd.Process.Pid)
verboseLogf("Logging cline-core output to: %s", logFilePath)
if Config.Verbose {
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
}
return cmd, nil
}
+5 -41
View File
@@ -37,17 +37,11 @@ var (
func InitializeGlobalConfig(cfg *GlobalConfig) error {
if cfg.ConfigPath == "" {
// Check CLINE_DIR environment variable first
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" {
cfg.ConfigPath = clineDir
} else {
// Fall back to default ~/.cline
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
}
// Ensure .cline directory exists
@@ -73,36 +67,6 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
return nil
}
// JsonFormat returns true if output format is set to JSON
func (cfg *GlobalConfig) JsonFormat() bool {
if cfg.OutputFormat == "" {
return false // Default is rich
}
return cfg.OutputFormat == "json"
}
// PlainFormat returns true if output format is set to plain
func (cfg *GlobalConfig) PlainFormat() bool {
if cfg.OutputFormat == "" {
return false // Default is rich
}
return cfg.OutputFormat == "plain"
}
// RichFormat returns true if output format is set to rich (or default)
func (c *GlobalConfig) RichFormat() bool {
return c.OutputFormat == "" || c.OutputFormat == "rich"
}
// MustNotBeJSON returns an error if JSON output mode is active.
// Use this at the start of interactive commands that cannot work with JSON output.
func (c *GlobalConfig) MustNotBeJSON(commandName string) error {
if c.JsonFormat() {
return fmt.Errorf("%s is an interactive command and cannot be used with --output-format json", commandName)
}
return nil
}
// GetDefaultClient returns a client for the default instance or the address override
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
@@ -146,4 +110,4 @@ func EnsureDefaultInstance(ctx context.Context) error {
}
return nil
}
}
+16 -79
View File
@@ -8,7 +8,6 @@ import (
"path/filepath"
"time"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/sqlite"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/client"
@@ -19,56 +18,6 @@ import (
"google.golang.org/grpc/health/grpc_health_v1"
)
// registryLog outputs a message respecting the current output format
func registryLog(message string, data map[string]interface{}) {
if Config.JsonFormat() {
output.OutputStatusMessage("info", message, data)
} else {
if len(data) > 0 {
// Format data for plain text output
fmt.Printf("%s:", message)
for k, v := range data {
fmt.Printf(" %s=%v", k, v)
}
fmt.Println()
} else {
fmt.Println(message)
}
}
}
// registryWarning outputs a warning message respecting the current output format
func registryWarning(message string, err error, data map[string]interface{}) {
if Config.JsonFormat() {
errData := data
if errData == nil {
errData = make(map[string]interface{})
}
if err != nil {
errData["error"] = err.Error()
}
output.OutputStatusMessage("warning", message, errData)
} else {
fmt.Printf("Warning: %s", message)
if err != nil {
fmt.Printf(": %v", err)
}
if len(data) > 0 {
fmt.Printf(" (")
first := true
for k, v := range data {
if !first {
fmt.Printf(", ")
}
fmt.Printf("%s=%v", k, v)
first = false
}
fmt.Printf(")")
}
fmt.Println()
}
}
// ClientRegistry manages Cline client connections using direct SQLite operations
type ClientRegistry struct {
lockManager *sqlite.LockManager
@@ -168,37 +117,32 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
// Database is unavailable - Return error instead of attempting cleanup
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
}
if !exists {
// Instance doesn't exist in database but config file references it
// This is a stale config - remove it and try to find another instance
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if removeErr := os.Remove(settingsPath); removeErr != nil && !os.IsNotExist(removeErr) {
registryWarning("Failed to remove stale default instance config", removeErr, nil)
fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr)
} else {
registryLog("Removed stale default instance config", map[string]interface{}{
"instance": defaultAddr,
"reason": "not found in database",
})
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
}
// Try to find and set a new default instance
instances := r.ListInstances()
if len(instances) > 0 {
if err := r.EnsureDefaultInstance(instances); err != nil {
return nil, fmt.Errorf("failed to set new default instance: %w", err)
}
// Retry with the new default
newDefaultAddr := r.GetDefaultInstance()
if newDefaultAddr != "" {
registryLog("Set new default instance", map[string]interface{}{
"instance": newDefaultAddr,
})
fmt.Printf("Set new default instance: %s\n", newDefaultAddr)
return r.GetClient(ctx, newDefaultAddr)
}
}
return nil, fmt.Errorf("no default instance configured")
}
}
@@ -218,7 +162,7 @@ func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
if err != nil {
registryWarning("Failed to list instances", err, nil)
fmt.Printf("Warning: Failed to list instances: %v\n", err)
return []*common.CoreInstanceInfo{}
}
@@ -233,7 +177,7 @@ func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
exists, err := r.lockManager.HasInstanceAtAddress(address)
if err != nil {
registryWarning("Failed to check instance existence", err, nil)
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
return false
}
@@ -256,10 +200,9 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
for _, instance := range instances {
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
// Try to gracefully shutdown the paired host process before cleanup
registryLog("Attempting to shutdown dangling host service", map[string]interface{}{
"hostServiceAddress": instance.HostServiceAddress,
"coreInstance": instance.Address,
})
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
instance.HostServiceAddress, instance.Address)
r.tryShutdownHostProcess(instance.HostServiceAddress)
// Remove from SQLite database
@@ -267,9 +210,7 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
}
registryLog("Removed stale instance", map[string]interface{}{
"instance": instance.Address,
})
fmt.Printf("Removed stale instance: %s\n", instance.Address)
}
}
@@ -304,13 +245,9 @@ func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
})
if err != nil {
registryWarning("Failed to request host bridge shutdown", err, map[string]interface{}{
"hostServiceAddress": hostServiceAddress,
})
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
} else {
registryLog("Host bridge shutdown requested successfully", map[string]interface{}{
"hostServiceAddress": hostServiceAddress,
})
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
}
}
@@ -324,7 +261,7 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co
// 3. Ensure default is set if instances exist
if err := r.EnsureDefaultInstance(instances); err != nil {
registryWarning("Failed to ensure default instance", err, nil)
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
}
return instances, nil
+4 -4
View File
@@ -22,10 +22,10 @@ type MessageHandler interface {
// DisplayContext provides context and utilities for message handlers
type DisplayContext struct {
State *types.ConversationState
Renderer *display.Renderer
ToolRenderer *display.ToolRenderer
SystemRenderer *display.SystemMessageRenderer
State *types.ConversationState
Renderer *display.Renderer
ToolRenderer *display.ToolRenderer
SystemRenderer *display.SystemMessageRenderer
IsLast bool
IsPartial bool
Verbose bool
+12 -11
View File
@@ -6,8 +6,8 @@ import (
"strings"
"github.com/cline/cli/pkg/cli/clerror"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/cli/pkg/cli/output"
)
// SayHandler handles SAY type messages
@@ -242,18 +242,19 @@ func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *Display
}
func formatUserMessage(text string) string {
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
}
// handleUserFeedback handles user feedback messages
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text != "" {
+75 -229
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
@@ -12,7 +11,6 @@ import (
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/common"
client2 "github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
@@ -104,22 +102,7 @@ func newInstanceKillCommand() *cobra.Command {
if killAllCLI {
return killAllCLIInstances(ctx, registry)
} else {
address := args[0]
if err := global.KillInstanceByAddress(ctx, registry, address); err != nil {
return err
}
// Output success in JSON or plain text
if global.Config.JsonFormat() {
data := map[string]interface{}{
"killedCount": 1,
"addresses": []string{address},
}
return output.OutputJSONSuccess("instance kill", data)
}
fmt.Printf("Successfully killed instance at %s\n", address)
return nil
return global.KillInstanceByAddress(ctx, registry, args[0])
}
},
}
@@ -137,16 +120,6 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
}
if len(instances) == 0 {
if global.Config.JsonFormat() {
data := map[string]interface{}{
"killedCount": 0,
"alreadyDeadCount": 0,
"failedCount": 0,
"skippedCount": 0,
"addresses": []string{},
}
return output.OutputJSONSuccess("instance kill", data)
}
fmt.Println("No Cline instances found to kill.")
return nil
}
@@ -154,7 +127,6 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
// Filter to only CLI instances
var cliInstances []*common.CoreInstanceInfo
var skippedNonCLI int
var skippedAddresses []string
for _, instance := range instances {
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
platform, err := detectInstancePlatform(ctx, instance)
@@ -163,28 +135,13 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
cliInstances = append(cliInstances, instance)
} else {
skippedNonCLI++
skippedAddresses = append(skippedAddresses, instance.Address)
if !global.Config.JsonFormat() {
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
}
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
}
}
}
}
if len(cliInstances) == 0 {
if global.Config.JsonFormat() {
data := map[string]interface{}{
"killedCount": 0,
"alreadyDeadCount": 0,
"failedCount": 0,
"skippedCount": skippedNonCLI,
"addresses": []string{},
"skippedAddresses": skippedAddresses,
}
return output.OutputJSONSuccess("instance kill", data)
}
if skippedNonCLI > 0 {
fmt.Printf("No CLI instances to kill. Skipped %d JetBrains instance(s).\n", skippedNonCLI)
} else {
@@ -193,45 +150,32 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
return nil
}
if !global.Config.JsonFormat() {
fmt.Printf("Killing %d CLI instance(s)...\n", len(cliInstances))
if skippedNonCLI > 0 {
fmt.Printf("Skipping %d JetBrains instance(s).\n", skippedNonCLI)
}
fmt.Printf("Killing %d CLI instance(s)...\n", len(cliInstances))
if skippedNonCLI > 0 {
fmt.Printf("Skipping %d JetBrains instance(s).\n", skippedNonCLI)
}
var killResults []killResult
killedAddresses := make(map[string]bool)
var killedAddressList []string
// Kill all CLI instances
for _, instance := range cliInstances {
result := killInstanceProcess(ctx, registry, instance.Address)
killResults = append(killResults, result)
if !global.Config.JsonFormat() {
if result.err != nil {
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
} else if result.alreadyDead {
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
} else {
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
killedAddresses[instance.Address] = true
killedAddressList = append(killedAddressList, instance.Address)
}
if result.err != nil {
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
} else if result.alreadyDead {
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
} else {
if !result.alreadyDead && result.err == nil {
killedAddresses[instance.Address] = true
killedAddressList = append(killedAddressList, instance.Address)
}
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
killedAddresses[instance.Address] = true
}
}
// Wait for killed instances to clean up their registry entries
if len(killedAddresses) > 0 {
if !global.Config.JsonFormat() {
fmt.Printf("Waiting for instances to clean up registry entries...\n")
}
fmt.Printf("Waiting for instances to clean up registry entries...\n")
maxWaitTime := 10 // seconds
for i := 0; i < maxWaitTime; i++ {
@@ -239,9 +183,7 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
remainingInstances, err := registry.ListInstancesCleaned(ctx)
if err != nil {
if !global.Config.JsonFormat() {
fmt.Printf("Warning: failed to check registry status: %v\n", err)
}
fmt.Printf("Warning: failed to check registry status: %v\n", err)
continue
}
@@ -254,13 +196,11 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
}
if len(stillPresent) == 0 {
if !global.Config.JsonFormat() {
fmt.Printf("✓ All killed instances successfully removed from registry.\n")
}
fmt.Printf("✓ All killed instances successfully removed from registry.\n")
break
}
if i == maxWaitTime-1 && !global.Config.JsonFormat() {
if i == maxWaitTime-1 {
fmt.Printf("⚠ %d killed instance(s) still in registry after %d seconds\n", len(stillPresent), maxWaitTime)
for _, addr := range stillPresent {
fmt.Printf(" - %s\n", addr)
@@ -269,7 +209,7 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
}
}
// Count results
// Print summary
successful := 0
failed := 0
alreadyDead := 0
@@ -284,25 +224,6 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
}
}
// Output results
if global.Config.JsonFormat() {
data := map[string]interface{}{
"killedCount": successful,
"alreadyDeadCount": alreadyDead,
"failedCount": failed,
"skippedCount": skippedNonCLI,
"addresses": killedAddressList,
}
if len(skippedAddresses) > 0 {
data["skippedAddresses"] = skippedAddresses
}
if failed > 0 {
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(cliInstances))
}
return output.OutputJSONSuccess("instance kill", data)
}
// Plain text summary
fmt.Printf("\nSummary: ")
if successful > 0 {
fmt.Printf("Successfully killed %d instances. ", successful)
@@ -312,7 +233,7 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
}
if failed > 0 {
fmt.Printf("%d failures.", failed)
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(cliInstances))
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
}
fmt.Println()
@@ -370,34 +291,33 @@ func newInstanceListCommand() *cobra.Command {
defaultInstance := registry.GetDefaultInstance()
if len(instances) == 0 {
if global.Config.JsonFormat() {
data := map[string]interface{}{
"defaultInstance": defaultInstance,
"instances": []interface{}{},
}
return output.OutputJSONSuccess("instance list", data)
}
fmt.Println("No Cline instances found.")
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
return nil
}
// Build instance data
type instanceData struct {
Address string `json:"address"`
Status string `json:"status"`
Version string `json:"version"`
LastSeen string `json:"lastSeen"`
PID string `json:"pid"`
Platform string `json:"platform"`
IsDefault bool `json:"isDefault"`
type instanceRow struct {
address string
status string
version string
lastSeen string
pid string
platform string
isDefault string
}
var instanceList []instanceData
var rows []instanceRow
for _, instance := range instances {
isDefaultBool := instance.Address == defaultInstance
isDefault := ""
if instance.Address == defaultInstance {
isDefault = "✓"
}
lastSeen := instance.LastSeen.Format(time.RFC3339)
lastSeen := instance.LastSeen.Format("15:04:05")
if time.Since(instance.LastSeen) > 24*time.Hour {
lastSeen = instance.LastSeen.Format("2006-01-02")
}
// Get PID and platform via RPC if instance is healthy
pid := platformNA
@@ -420,52 +340,32 @@ func newInstanceListCommand() *cobra.Command {
}
}
instanceList = append(instanceList, instanceData{
Address: instance.Address,
Status: instance.Status.String(),
Version: instance.Version,
LastSeen: lastSeen,
PID: pid,
Platform: platform,
IsDefault: isDefaultBool,
rows = append(rows, instanceRow{
address: instance.Address,
status: instance.Status.String(),
version: instance.Version,
lastSeen: lastSeen,
pid: pid,
platform: platform,
isDefault: isDefault,
})
}
// Check for JSON output mode first
if global.Config.JsonFormat() {
data := map[string]interface{}{
"defaultInstance": defaultInstance,
"instances": instanceList,
}
return output.OutputJSONSuccess("instance list", data)
}
// Check output format for plain/rich
if global.Config.PlainFormat() {
// Check output format
if global.Config.OutputFormat == "plain" {
// Use tabwriter for plain output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tPLATFORM\tDEFAULT")
for _, inst := range instanceList {
isDefaultStr := ""
if inst.IsDefault {
isDefaultStr = "✓"
}
// Format lastSeen for display
lastSeenTime, _ := time.Parse(time.RFC3339, inst.LastSeen)
lastSeenDisplay := lastSeenTime.Format("15:04:05")
if time.Since(lastSeenTime) > 24*time.Hour {
lastSeenDisplay = lastSeenTime.Format("2006-01-02")
}
for _, row := range rows {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
inst.Address,
inst.Status,
inst.Version,
lastSeenDisplay,
inst.PID,
inst.Platform,
isDefaultStr,
row.address,
row.status,
row.version,
row.lastSeen,
row.pid,
row.platform,
row.isDefault,
)
}
@@ -476,26 +376,15 @@ func newInstanceListCommand() *cobra.Command {
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **PLATFORM** | **DEFAULT** |\n")
markdown.WriteString("|---------|--------|---------|-----------|-----|----------|---------|")
for _, inst := range instanceList {
isDefaultStr := ""
if inst.IsDefault {
isDefaultStr = "✓"
}
// Format lastSeen for display
lastSeenTime, _ := time.Parse(time.RFC3339, inst.LastSeen)
lastSeenDisplay := lastSeenTime.Format("15:04:05")
if time.Since(lastSeenTime) > 24*time.Hour {
lastSeenDisplay = lastSeenTime.Format("2006-01-02")
}
for _, row := range rows {
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s | %s |",
inst.Address,
inst.Status,
inst.Version,
lastSeenDisplay,
inst.PID,
inst.Platform,
isDefaultStr,
row.address,
row.status,
row.version,
row.lastSeen,
row.pid,
row.platform,
row.isDefault,
))
}
@@ -556,14 +445,6 @@ func newInstanceDefaultCommand() *cobra.Command {
return fmt.Errorf("failed to set default instance: %w", err)
}
// Output success in JSON or plain text
if global.Config.JsonFormat() {
data := map[string]interface{}{
"defaultInstance": address,
}
return output.OutputJSONSuccess("instance default", data)
}
fmt.Printf("Switched to instance: %s\n", address)
return nil
},
@@ -587,67 +468,32 @@ func newInstanceNewCommand() *cobra.Command {
return fmt.Errorf("clients not initialized")
}
// Output starting message
if global.Config.Verbose {
global.VerboseLog("instance new", "Starting new Cline instance...")
} else if !global.Config.JsonFormat() {
fmt.Println("Starting new Cline instance...")
}
fmt.Println("Starting new Cline instance...")
instance, err := global.Clients.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start instance: %w", err)
}
registry := global.Clients.GetRegistry()
// If --default flag provided, set this instance as the default
if setDefault {
if global.Config.Verbose {
global.VerboseLog("instance new", "Setting as default instance...")
}
if err := registry.SetDefaultInstance(instance.Address); err != nil {
// Output warning in appropriate format
if global.Config.JsonFormat() {
statusMsg := map[string]interface{}{
"type": "status",
"message": fmt.Sprintf("Warning: Failed to set as default: %v", err),
}
if jsonBytes, err := json.MarshalIndent(statusMsg, "", " "); err == nil {
fmt.Println(string(jsonBytes))
}
} else {
fmt.Printf("Warning: Failed to set as default: %v\n", err)
}
} else if global.Config.Verbose {
global.VerboseLog("instance new", fmt.Sprintf("Set %s as default instance", instance.Address))
}
}
// Check if this is the default instance
isDefault := registry.GetDefaultInstance() == instance.Address
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"address": instance.Address,
"corePort": instance.CorePort(),
"hostPort": instance.HostPort(),
"isDefault": isDefault,
}
return output.OutputJSONSuccess("instance new", data)
}
// Existing rich/plain output
fmt.Printf("Successfully started new instance:\n")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
registry := global.Clients.GetRegistry()
// If --default flag provided, set this instance as the default
if setDefault {
fmt.Printf(" Status: Set as default instance\n")
} else if isDefault {
fmt.Printf(" Status: Default instance\n")
if err := registry.SetDefaultInstance(instance.Address); err != nil {
fmt.Printf("Warning: Failed to set as default: %v\n", err)
} else {
fmt.Printf(" Status: Set as default instance\n")
}
} else {
// Otherwise, check if EnsureDefaultInstance already set it as default
if registry.GetDefaultInstance() == instance.Address {
fmt.Printf(" Status: Default instance\n")
}
}
return nil
@@ -657,4 +503,4 @@ func newInstanceNewCommand() *cobra.Command {
cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance")
return cmd
}
}
+1 -76
View File
@@ -11,7 +11,6 @@ import (
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/spf13/cobra"
)
@@ -55,45 +54,11 @@ func newLogsListCommand() *cobra.Command {
}
if len(logs) == 0 {
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"logsDir": logsDir,
"logs": []interface{}{},
}
return output.OutputJSONSuccess("logs list", data)
}
fmt.Println("No log files found.")
fmt.Printf("Log files will be created in: %s\n", logsDir)
return nil
}
// Check for JSON output mode
if global.Config.JsonFormat() {
type logData struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
SizeFormatted string `json:"sizeFormatted"`
Created string `json:"created"`
Age string `json:"age"`
}
var jsonLogs []logData
for _, log := range logs {
jsonLogs = append(jsonLogs, logData{
Filename: log.name,
Size: log.size,
SizeFormatted: formatFileSize(log.size),
Created: log.created.Format(time.RFC3339),
Age: formatAge(log.created),
})
}
data := map[string]interface{}{
"logsDir": logsDir,
"logs": jsonLogs,
}
return output.OutputJSONSuccess("logs list", data)
}
return renderLogsTable(logs, false)
},
}
@@ -130,16 +95,6 @@ func newLogsCleanCommand() *cobra.Command {
}
if len(toDelete) == 0 {
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"deletedCount": 0,
"bytesFreed": 0,
"formattedSize": "0 B",
"dryRun": dryRun,
}
return output.OutputJSONSuccess("logs clean", data)
}
if all {
fmt.Println("No log files to delete.")
} else {
@@ -155,16 +110,6 @@ func newLogsCleanCommand() *cobra.Command {
}
if dryRun {
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"deletedCount": len(toDelete),
"bytesFreed": totalSize,
"formattedSize": formatFileSize(totalSize),
"dryRun": true,
}
return output.OutputJSONSuccess("logs clean", data)
}
fmt.Println("The following log files will be deleted:\n")
if err := renderLogsTable(toDelete, true); err != nil {
return err
@@ -184,17 +129,6 @@ func newLogsCleanCommand() *cobra.Command {
return fmt.Errorf("failed to delete log files: %w", err)
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"deletedCount": count,
"bytesFreed": bytesFreed,
"formattedSize": formatFileSize(bytesFreed),
"dryRun": false,
}
return output.OutputJSONSuccess("logs clean", data)
}
fileWord := "files"
if count == 1 {
fileWord = "file"
@@ -222,15 +156,6 @@ func newLogsPathCommand() *cobra.Command {
}
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"path": logsDir,
}
return output.OutputJSONSuccess("logs path", data)
}
fmt.Println(logsDir)
return nil
},
@@ -396,7 +321,7 @@ func renderLogsTable(logs []logFileInfo, markForDeletion bool) error {
}
// Check output format
if global.Config.PlainFormat() {
if global.Config.OutputFormat == "plain" {
// Use tabwriter for plain output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "FILENAME\tSIZE\tCREATED\tAGE")
+1 -1
View File
@@ -20,7 +20,7 @@ type OutputCoordinator struct {
mu sync.Mutex
program *tea.Program
inputVisible atomic.Bool
inputModel *InputModel // Reference to current input model for state restoration
inputModel *InputModel // Reference to current input model for state restoration
restartCallback func(*InputModel) // Callback to restart the program with preserved state
}
+14 -14
View File
@@ -14,7 +14,7 @@ import (
// InputType represents the type of input being collected
type InputType int
const INPUT_WIDTH = 46
const INPUT_WIDTH = 46
const (
InputTypeMessage InputType = iota
@@ -24,11 +24,11 @@ const (
// InputSubmitMsg is sent when the user submits input
type InputSubmitMsg struct {
Value string
InputType InputType
Approved bool // For approval type
NeedsFeedback bool // For approval type
NoAskAgain bool // For approval type - indicates "don't ask again" was selected
Value string
InputType InputType
Approved bool // For approval type
NeedsFeedback bool // For approval type
NoAskAgain bool // For approval type - indicates "don't ask again" was selected
}
// InputCancelMsg is sent when the user cancels input (Ctrl+C)
@@ -36,8 +36,8 @@ type InputCancelMsg struct{}
// ChangeInputTypeMsg changes the current input type
type ChangeInputTypeMsg struct {
InputType InputType
Title string
InputType InputType
Title string
Placeholder string
}
@@ -57,7 +57,7 @@ type InputModel struct {
placeholder string
currentMode string // "plan" or "act"
width int
lastHeight int // Track height for cleanup on submit
lastHeight int // Track height for cleanup on submit
// For approval type
approvalOptions []string
@@ -120,7 +120,7 @@ func NewInputModel(inputType InputType, title, placeholder, currentMode string)
ta.Focus()
ta.CharLimit = 0
ta.ShowLineNumbers = false
ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!)
ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!)
ta.SetHeight(5)
// Don't set width here - let WindowSizeMsg handle it
ta.SetWidth(INPUT_WIDTH)
@@ -138,11 +138,11 @@ func NewInputModel(inputType InputType, title, placeholder, currentMode string)
cursorColor = lipgloss.Color("39") // Blue for act
}
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling
ta.FocusedStyle.Placeholder = styles.placeholder
ta.FocusedStyle.Text = styles.textArea
ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling
ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling
ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor)
ta.Cursor.TextStyle = styles.textArea
@@ -411,7 +411,7 @@ func (m *InputModel) Clone() *InputModel {
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.SetHeight(5)
ta.SetWidth(INPUT_WIDTH)
ta.SetWidth(INPUT_WIDTH)
ta.Focus()
// Configure keybindings
-108
View File
@@ -1,108 +0,0 @@
package output
import (
"encoding/json"
"fmt"
)
// OutputJSONLine outputs a single JSON object as a line (JSONL format)
func OutputJSONLine(obj map[string]interface{}) error {
jsonBytes, err := json.Marshal(obj)
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// OutputStatusMessage outputs a status message in JSONL format
// Caller should check output mode before calling this function
func OutputStatusMessage(msgType, message string, data map[string]interface{}) error {
obj := map[string]interface{}{
"type": msgType,
"message": message,
}
if data != nil {
for k, v := range data {
obj[k] = v
}
}
return OutputJSONLine(obj)
}
// JSONResponse represents a standard CLI JSON response
type JSONResponse struct {
Status string `json:"status"` // "success" or "error"
Command string `json:"command"` // e.g., "instance list"
Data interface{} `json:"data,omitempty"` // Response data (only for success)
Error string `json:"error,omitempty"` // Error message (only for error)
}
// FormatJSONResponse creates a JSON response string
func FormatJSONResponse(status, command string, data interface{}, errMsg string) (string, error) {
response := JSONResponse{
Status: status,
Command: command,
Data: data,
Error: errMsg,
}
jsonBytes, err := json.MarshalIndent(response, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal JSON response: %w", err)
}
return string(jsonBytes), nil
}
// OutputJSON prints a JSON response to stdout
func OutputJSON(status, command string, data interface{}, errMsg string) error {
jsonStr, err := FormatJSONResponse(status, command, data, errMsg)
if err != nil {
return err
}
fmt.Println(jsonStr)
return nil
}
// OutputCommandStatus outputs a command status message in batch format
func OutputCommandStatus(command, status, message string, data map[string]interface{}) error {
obj := map[string]interface{}{
"type": "command",
"command": command,
"status": status,
}
if message != "" {
obj["message"] = message
}
if data != nil {
obj["data"] = data
}
return OutputJSONLine(obj)
}
// OutputJSONSuccess outputs a successful JSON response as a single JSONL line
func OutputJSONSuccess(command string, data interface{}) error {
response := map[string]interface{}{
"type": "command",
"command": command,
"status": "success",
"data": data,
}
return OutputJSONLine(response)
}
// OutputJSONError outputs an error JSON response
func OutputJSONError(command string, err error) error {
errMsg := ""
if err != nil {
errMsg = err.Error()
}
return OutputJSON("error", command, nil, errMsg)
}
-197
View File
@@ -1,197 +0,0 @@
package output
import (
"encoding/json"
"errors"
"testing"
)
// TestFormatJSONResponse tests the FormatJSONResponse function
func TestFormatJSONResponse(t *testing.T) {
tests := []struct {
name string
status string
command string
data interface{}
errMsg string
wantErr bool
validate func(*testing.T, string)
}{
{
name: "success response with simple data",
status: "success",
command: "test",
data: map[string]string{"key": "value"},
errMsg: "",
wantErr: false,
validate: func(t *testing.T, result string) {
var resp JSONResponse
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if resp.Status != "success" {
t.Errorf("expected status=success, got %s", resp.Status)
}
if resp.Command != "test" {
t.Errorf("expected command=test, got %s", resp.Command)
}
if resp.Error != "" {
t.Errorf("expected no error, got %s", resp.Error)
}
},
},
{
name: "error response",
status: "error",
command: "test",
data: nil,
errMsg: "something went wrong",
wantErr: false,
validate: func(t *testing.T, result string) {
var resp JSONResponse
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if resp.Status != "error" {
t.Errorf("expected status=error, got %s", resp.Status)
}
if resp.Error != "something went wrong" {
t.Errorf("expected error message, got %s", resp.Error)
}
},
},
{
name: "success with complex nested data",
status: "success",
command: "version",
data: map[string]interface{}{
"cliVersion": "1.0.0",
"nested": map[string]string{
"key": "value",
},
"array": []string{"a", "b", "c"},
},
errMsg: "",
wantErr: false,
validate: func(t *testing.T, result string) {
var resp JSONResponse
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
data := resp.Data.(map[string]interface{})
if data["cliVersion"] != "1.0.0" {
t.Errorf("expected cliVersion=1.0.0")
}
},
},
{
name: "nil data with success",
status: "success",
command: "test",
data: nil,
errMsg: "",
wantErr: false,
validate: func(t *testing.T, result string) {
var resp JSONResponse
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if resp.Data != nil {
t.Errorf("expected nil data, got %v", resp.Data)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := FormatJSONResponse(tt.status, tt.command, tt.data, tt.errMsg)
if (err != nil) != tt.wantErr {
t.Errorf("FormatJSONResponse() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.validate != nil {
tt.validate(t, result)
}
})
}
}
// TestOutputJSONSuccess tests the OutputJSONSuccess helper
func TestOutputJSONSuccess(t *testing.T) {
// Note: This test would need to capture stdout which is complex
// For now, we test that it doesn't panic and returns no error
// The output should use "data" field, not "result"
data := map[string]string{"test": "value"}
err := OutputJSONSuccess("test", data)
if err != nil {
t.Errorf("OutputJSONSuccess() returned error: %v", err)
}
}
// TestOutputJSONError tests the OutputJSONError helper
func TestOutputJSONError(t *testing.T) {
// Note: This test would need to capture stdout which is complex
// For now, we test that it doesn't panic and returns no error
err := OutputJSONError("test", errors.New("test error"))
if err != nil {
t.Errorf("OutputJSONError() returned error: %v", err)
}
}
// TestJSONResponseMarshaling tests that JSONResponse can be marshaled correctly
func TestJSONResponseMarshaling(t *testing.T) {
tests := []struct {
name string
response JSONResponse
validate func(*testing.T, []byte)
}{
{
name: "success response",
response: JSONResponse{
Status: "success",
Command: "test",
Data: map[string]string{"key": "value"},
},
validate: func(t *testing.T, data []byte) {
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["status"] != "success" {
t.Errorf("expected status=success")
}
if result["error"] != nil {
t.Errorf("expected error field to be omitted or nil")
}
},
},
{
name: "error response",
response: JSONResponse{
Status: "error",
Command: "test",
Error: "test error",
},
validate: func(t *testing.T, data []byte) {
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if result["error"] != "test error" {
t.Errorf("expected error=test error")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(tt.response)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
tt.validate(t, data)
})
}
}
+5 -5
View File
@@ -19,20 +19,20 @@ import (
// Handles localhost/127.0.0.1 equivalence by returning both forms.
func normalizeAddressVariants(address string) []string {
variants := []string{address}
// Extract host and port
host, port, err := net.SplitHostPort(address)
if err != nil {
return variants
}
// Add the alternate form for localhost/127.0.0.1
if host == "localhost" {
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
} else if host == "127.0.0.1" {
variants = append(variants, net.JoinHostPort("localhost", port))
}
return variants
}
@@ -182,7 +182,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
query := common.SelectInstanceLockByHolderSQL
variants := normalizeAddressVariants(address)
var heldBy, lockTarget string
var lockedAt int64
var lastErr error
@@ -204,7 +204,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
lastErr = err
}
}
// None of the variants were found
if lastErr != nil {
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
+20 -157
View File
@@ -12,29 +12,12 @@ import (
"github.com/cline/cli/pkg/cli/config"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/cli/pkg/cli/updater"
"github.com/cline/grpc-go/cline"
"github.com/spf13/cobra"
)
// outputVerbose outputs a verbose message either as JSONL (in JSON mode) or as plain text
func outputVerbose(format string, args ...interface{}) {
if !global.Config.Verbose {
return
}
message := fmt.Sprintf(format, args...)
if global.Config.JsonFormat() {
// Output as JSONL immediately
output.OutputStatusMessage("verbose", message, nil)
} else {
fmt.Println(message)
}
}
// TaskOptions contains options for creating a task
type TaskOptions struct {
Images []string
@@ -74,11 +57,10 @@ func ensureTaskManager(ctx context.Context, address string) error {
var instanceAddress string
if address != "" {
// Ensure instance exists at the specified address (waits for registration)
// Ensure instance exists at the specified address
if err := ensureInstanceAtAddress(ctx, address); err != nil {
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
}
taskManager, err = task.NewManagerForAddress(ctx, address)
instanceAddress = address
} else {
@@ -155,19 +137,14 @@ func newTaskNewCommand() *cobra.Command {
return err
}
if global.Config.Verbose {
global.VerboseLog("task new", fmt.Sprintf("Creating task with prompt length: %d", len(prompt)))
}
// Set mode if provided
if mode != "" {
if global.Config.Verbose {
global.VerboseLog("task new", fmt.Sprintf("Setting mode to: %s", mode))
}
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
return fmt.Errorf("failed to set mode: %w", err)
}
outputVerbose("Mode set to: %s", mode)
if global.Config.Verbose {
fmt.Printf("Mode set to: %s\n", mode)
}
}
// Inject yolo_mode_toggled setting if --yolo flag is set
@@ -176,9 +153,6 @@ func newTaskNewCommand() *cobra.Command {
// If the yoloMode is also set in the settings, this will override that, since it will be set last.
if yolo {
settings = append(settings, "yolo_mode_toggled=true")
if global.Config.Verbose {
global.VerboseLog("task new", "Yolo mode enabled")
}
}
// Create the task
@@ -187,17 +161,10 @@ func newTaskNewCommand() *cobra.Command {
return fmt.Errorf("failed to create task: %w", err)
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"taskId": taskID,
"instance": taskManager.GetCurrentInstance(),
}
return output.OutputJSONSuccess("task new", data)
if global.Config.Verbose {
fmt.Printf("Task created successfully with ID: %s\n", taskID)
}
outputVerbose("Task created successfully with ID: %s", taskID)
return nil
},
}
@@ -231,15 +198,6 @@ func newTaskPauseCommand() *cobra.Command {
return err
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"cancelled": true,
"instance": taskManager.GetCurrentInstance(),
}
return output.OutputJSONSuccess("task pause", data)
}
fmt.Println("Task paused successfully")
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
return nil
@@ -304,16 +262,10 @@ func newTaskSendCommand() *cobra.Command {
if err != nil {
// Handle specific error cases
if errors.Is(err, task.ErrNoActiveTask) {
if global.Config.JsonFormat() {
return output.OutputJSONError("task send", fmt.Errorf("no active task"))
}
fmt.Println("Cannot send message: no active task")
return nil
}
if errors.Is(err, task.ErrTaskBusy) {
if global.Config.JsonFormat() {
return output.OutputJSONError("task send", fmt.Errorf("task is currently busy"))
}
fmt.Println("Cannot send message: task is currently busy")
return nil
}
@@ -343,17 +295,6 @@ func newTaskSendCommand() *cobra.Command {
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
return fmt.Errorf("failed to set mode and send message: %w", err)
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"sent": true,
"mode": mode,
"instance": taskManager.GetCurrentInstance(),
}
return output.OutputJSONSuccess("task send", data)
}
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
} else {
@@ -369,16 +310,6 @@ func newTaskSendCommand() *cobra.Command {
if err := taskManager.SendMessage(ctx, message, images, files, approveStr); err != nil {
return err
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]interface{}{
"sent": true,
"instance": taskManager.GetCurrentInstance(),
}
return output.OutputJSONSuccess("task send", data)
}
fmt.Printf("Message sent successfully.\n")
}
@@ -407,18 +338,8 @@ func newTaskChatCommand() *cobra.Command {
Aliases: []string{"c"},
Short: "Chat with the current task in interactive mode",
Long: `Chat with the current task, displaying messages in real-time with interactive input enabled.`,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Interactive commands cannot work with JSON output
return global.Config.MustNotBeJSON("task chat")
},
Args: cobra.NoArgs,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Check for JSON output mode - not supported for interactive commands
// Per the plan: Interactive commands output PLAIN TEXT errors, not JSON
if err := global.Config.MustNotBeJSON("task chat"); err != nil {
return err
}
ctx := cmd.Context()
if err := ensureTaskManager(ctx, address); err != nil {
@@ -466,14 +387,7 @@ func newTaskViewCommand() *cobra.Command {
return err
}
// Output instance info
if global.Config.JsonFormat() {
output.OutputStatusMessage("status", "Using instance", map[string]interface{}{
"instance": taskManager.GetCurrentInstance(),
})
} else {
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
}
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
if follow {
// Follow conversation forever (non-interactive)
@@ -496,8 +410,6 @@ func newTaskViewCommand() *cobra.Command {
}
func newTaskListCommand() *cobra.Command {
var address string
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
@@ -505,46 +417,11 @@ func newTaskListCommand() *cobra.Command {
Long: `Display recent tasks from task history.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Ensure task manager is initialized
if err := ensureTaskManager(ctx, address); err != nil {
return err
}
// Fetch task history from the server
resp, err := taskManager.GetClient().Task.GetTaskHistory(ctx, &cline.GetTaskHistoryRequest{})
if err != nil {
return fmt.Errorf("failed to get task history: %w", err)
}
// Check for JSON output mode
if global.Config.JsonFormat() {
// Convert tasks to simple map format
tasks := make([]map[string]interface{}, 0, len(resp.Tasks))
for _, taskItem := range resp.Tasks {
taskData := map[string]interface{}{
"id": taskItem.Id,
"task": taskItem.Task,
"ts": taskItem.Ts,
"totalCost": taskItem.TotalCost,
}
tasks = append(tasks, taskData)
}
data := map[string]interface{}{
"tasks": tasks,
"total": len(resp.Tasks),
}
return output.OutputJSONSuccess("task list", data)
}
// Render the task list (plain/rich mode)
return taskManager.GetRenderer().RenderTaskList(resp.Tasks)
// Read directly from disk
return task.ListTasksFromDisk()
},
}
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
@@ -571,16 +448,7 @@ func newTaskOpenCommand() *cobra.Command {
return err
}
// Output instance info
if global.Config.Verbose {
global.VerboseLog("task open", fmt.Sprintf("Using instance: %s", taskManager.GetCurrentInstance()))
} else if !global.Config.JsonFormat() {
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
}
if global.Config.Verbose {
global.VerboseLog("task open", fmt.Sprintf("Resuming task: %s", taskID))
}
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
// Resume the task
if err := taskManager.ResumeTask(ctx, taskID); err != nil {
@@ -592,7 +460,9 @@ func newTaskOpenCommand() *cobra.Command {
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
return fmt.Errorf("failed to set mode: %w", err)
}
outputVerbose("Mode set to: %s", mode)
if global.Config.Verbose {
fmt.Printf("Mode set to: %s\n", mode)
}
}
// Process yolo flag and apply settings
@@ -638,17 +508,6 @@ func newTaskOpenCommand() *cobra.Command {
}
}
// Output success in JSON or plain text
if global.Config.JsonFormat() {
data := map[string]interface{}{
"taskId": taskID,
"resumed": true,
"instance": taskManager.GetCurrentInstance(),
}
return output.OutputJSONSuccess("task open", data)
}
fmt.Printf("Task %s opened successfully\n", taskID)
return nil
},
}
@@ -783,7 +642,9 @@ func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) e
if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil {
return fmt.Errorf("failed to set mode: %w", err)
}
outputVerbose("Mode set to: %s", opts.Mode)
if global.Config.Verbose {
fmt.Printf("Mode set to: %s\n", opts.Mode)
}
}
// Inject yolo_mode_toggled setting if --yolo flag is set
@@ -797,7 +658,9 @@ func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) e
return fmt.Errorf("failed to create task: %w", err)
}
outputVerbose("Task created successfully with ID: %s\n", taskID)
if global.Config.Verbose {
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
}
// Check for updates in background after task is created
updater.CheckAndUpdate(opts.Verbose)
+10 -10
View File
@@ -38,13 +38,13 @@ type InputHandler struct {
// NewInputHandler creates a new input handler
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
return &InputHandler{
manager: manager,
coordinator: coordinator,
cancelFunc: cancelFunc,
isRunning: false,
pollTicker: time.NewTicker(500 * time.Millisecond),
resultChan: make(chan output.InputSubmitMsg, 1),
cancelChan: make(chan struct{}, 1),
manager: manager,
coordinator: coordinator,
cancelFunc: cancelFunc,
isRunning: false,
pollTicker: time.NewTicker(500 * time.Millisecond),
resultChan: make(chan output.InputSubmitMsg, 1),
cancelChan: make(chan struct{}, 1),
}
}
@@ -294,7 +294,7 @@ func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
// Store the approval message for later use in determining auto-approval action
ih.approvalMessage = msg
model := output.NewInputModel(
output.InputTypeApproval,
"Let Cline use this tool?",
@@ -394,7 +394,7 @@ func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputM
// Need to collect feedback - will be handled by model state change
return "", false, nil
}
// Check if NoAskAgain was selected
if result.NoAskAgain && result.Approved && ih.approvalMessage != nil {
// Determine which auto-approval action to enable
@@ -410,7 +410,7 @@ func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputM
}
}
}
// Store approval state for when feedback comes back
ih.feedbackApproval = false
ih.feedbackApproved = result.Approved
+1
View File
@@ -8,6 +8,7 @@ import (
"github.com/cline/grpc-go/cline"
)
func ParseTaskSettings(settingsFlags []string) (*cline.Settings, *cline.Secrets, error) {
if len(settingsFlags) == 0 {
return nil, nil, nil
+64 -27
View File
@@ -29,7 +29,7 @@ type npmRegistryResponse struct {
}
const (
checkInterval = 24 * time.Hour
checkInterval = 24 * time.Hour
requestTimeout = 3 * time.Second
)
@@ -48,22 +48,30 @@ func CheckAndUpdate(isVerbose bool) {
// Skip in CI environments
if os.Getenv("CI") != "" {
global.VerboseLog("version", "[updater] Skipping update check (CI environment)")
if verbose {
output.Printf("[updater] Skipping update check (CI environment)\n")
}
return
}
// Skip if user disabled auto-updates
if os.Getenv("NO_AUTO_UPDATE") != "" {
global.VerboseLog("version", "[updater] Skipping update check (NO_AUTO_UPDATE set)")
if verbose {
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
}
return
}
global.VerboseLog("version", "[updater] Starting background update check")
if verbose {
output.Printf("[updater] Starting background update check...\n")
}
// Run in background so we don't block CLI startup
go func() {
if err := checkAndUpdateInternal(false); err != nil {
global.VerboseLogf("version", "[updater] Update check failed: %v", err)
if verbose {
output.Printf("[updater] Update check failed: %v\n", err)
}
}
}()
}
@@ -76,37 +84,49 @@ func CheckAndUpdateSync(isVerbose bool, bypassCache bool) {
// Skip in CI environments
if os.Getenv("CI") != "" {
global.VerboseLog("version", "[updater] Skipping update check (CI environment)")
if verbose {
output.Printf("[updater] Skipping update check (CI environment)\n")
}
return
}
// Skip if user disabled auto-updates
if os.Getenv("NO_AUTO_UPDATE") != "" {
global.VerboseLog("version", "[updater] Skipping update check (NO_AUTO_UPDATE set)")
if verbose {
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
}
return
}
global.VerboseLog("version", "[updater] Starting update check")
if verbose {
output.Printf("[updater] Starting update check...\n")
}
// Run synchronously
if err := checkAndUpdateInternal(bypassCache); err != nil {
global.VerboseLogf("version", "[updater] Update check failed: %v", err)
if verbose {
output.Printf("[updater] Update check failed: %v\n", err)
}
}
}
func checkAndUpdateInternal(bypassCache bool) error {
global.VerboseLog("version", "[updater] Loading update cache")
if verbose {
output.Printf("[updater] Loading update cache...\n")
}
// Load cache
cache, err := loadCache()
if !bypassCache && err == nil && time.Since(cache.LastCheck) < checkInterval {
// Checked recently, skip (unless cache is bypassed)
global.VerboseLogf("version", "[updater] Cache is fresh (last checked %v ago), skipping", time.Since(cache.LastCheck))
if verbose {
output.Printf("[updater] Cache is fresh (last checked %v ago), skipping\n", time.Since(cache.LastCheck))
}
return nil
}
if err != nil {
global.VerboseLogf("version", "[updater] Cache load failed or doesn't exist: %v", err)
if err != nil && verbose {
output.Printf("[updater] Cache load failed or doesn't exist: %v\n", err)
}
// Determine channel
@@ -115,17 +135,23 @@ func checkAndUpdateInternal(bypassCache bool) error {
distTag = "nightly"
}
global.VerboseLogf("version", "[updater] Current version: %s (channel: %s)", global.CliVersion, distTag)
global.VerboseLog("version", "[updater] Fetching latest version from npm registry")
if verbose {
output.Printf("[updater] Current version: %s (channel: %s)\n", global.CliVersion, distTag)
output.Printf("[updater] Fetching latest version from npm registry...\n")
}
// Fetch latest version from npm
latestVersion, err := fetchLatestVersion()
if err != nil {
global.VerboseLogf("version", "[updater] Failed to fetch latest version: %v", err)
if verbose {
output.Printf("[updater] Failed to fetch latest version: %v\n", err)
}
return err
}
global.VerboseLogf("version", "[updater] Latest version on npm: %s", latestVersion)
if verbose {
output.Printf("[updater] Latest version on npm: %s\n", latestVersion)
}
// Update cache
cache = cacheData{
@@ -134,21 +160,29 @@ func checkAndUpdateInternal(bypassCache bool) error {
}
saveCache(cache)
global.VerboseLog("version", "[updater] Updated cache")
if verbose {
output.Printf("[updater] Updated cache\n")
}
// Compare versions
currentVersion := strings.TrimPrefix(global.CliVersion, "v")
latestVersion = strings.TrimPrefix(latestVersion, "v")
global.VerboseLogf("version", "[updater] Comparing versions: current=%s latest=%s", currentVersion, latestVersion)
if verbose {
output.Printf("[updater] Comparing versions: current=%s latest=%s\n", currentVersion, latestVersion)
}
if !isNewer(latestVersion, currentVersion) {
// Already up to date
global.VerboseLog("version", "[updater] Already on latest version, no update needed")
if verbose {
output.Printf("[updater] Already on latest version, no update needed\n")
}
return nil
}
global.VerboseLog("version", "[updater] Update available! Attempting to install")
if verbose {
output.Printf("[updater] Update available! Attempting to install...\n")
}
// Determine channel for update command
channel := "latest"
@@ -157,19 +191,22 @@ func checkAndUpdateInternal(bypassCache bool) error {
}
// Attempt update
packageName := "cline"
if channel == "nightly" {
packageName = "cline@" + channel
if verbose {
output.Printf("[updater] Running: npm install -g cline%s\n",
map[bool]string{true: "@"+channel, false: ""}[channel == "nightly"])
}
global.VerboseLogf("version", "[updater] Running: npm install -g %s", packageName)
if err := attemptUpdate(channel); err != nil {
global.VerboseLogf("version", "[updater] Update failed: %v", err)
if verbose {
output.Printf("[updater] Update failed: %v\n", err)
}
showFailureMessage(channel)
return err
}
global.VerboseLog("version", "[updater] Update completed successfully!")
if verbose {
output.Printf("[updater] Update completed successfully!\n")
}
showSuccessMessage(latestVersion)
return nil
+2 -20
View File
@@ -5,7 +5,6 @@ import (
"runtime"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/spf13/cobra"
)
@@ -21,27 +20,10 @@ func NewVersionCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
// Versions are injected at build time via ldflags
if short {
// --short flag always outputs plain version, even in JSON mode
fmt.Println(global.CliVersion)
return nil
}
// Check for JSON output mode
if global.Config.JsonFormat() {
data := map[string]string{
"cliVersion": global.CliVersion,
"coreVersion": global.Version,
"commit": global.Commit,
"date": global.Date,
"builtBy": global.BuiltBy,
"goVersion": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
return output.OutputJSONSuccess("version", data)
}
// Existing rich/plain output
fmt.Printf("Cline CLI\n")
fmt.Printf("Cline CLI Version: %s\n", global.CliVersion)
fmt.Printf("Cline Core Version: %s\n", global.Version)
@@ -55,7 +37,7 @@ func NewVersionCommand() *cobra.Command {
},
}
cmd.Flags().BoolVar(&short, "short", false, "show only version number (outputs plain text, overrides --output-format)")
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
return cmd
}
}
-187
View File
@@ -1,187 +0,0 @@
# JSON Output Demonstration Script
This script (`demo-json-output.sh`) demonstrates all CLI command and output format combinations that are tested in the e2e test suite.
## Purpose
Shows real-world JSON output from every command in these categories:
- **Version commands** - JSON output with -F json flag
- **Instance commands** - new, list, kill, default
- **Logs commands** - path, list, clean
- **Config commands** - list, get, set
- **Task commands** - new, list, open, view, send, pause, restore
- **Verbose output** - JSONL debug messages with `--verbose` flag
- **Interactive commands** - Proper rejection in JSON mode
- **Format validation** - JSON purity checks (no text leakage)
**Note:** This script demonstrates JSON output only. It uses the `-F json` flag (short form of `--output-format json`) for all commands.
## Usage
```bash
# From the cli directory
./scripts/demo-json-output.sh
# Or from the project root
./cli/scripts/demo-json-output.sh
```
## What It Does
1. **Creates temporary CLINE_DIR** - Isolated environment for testing
2. **Runs every command** from the e2e test suite with output format variations
3. **Shows command and output** - Each test displays the command and its result
4. **Color-coded output** - Easy to read with color highlighting
5. **Automatic cleanup** - Kills instances and removes temp directory on exit
## Output Format
Each test section shows:
```
=================================================================================
TEST: TestJSONOutputVersion - version with JSON output
=================================================================================
Command: version --output-format json
Output:
{
"status": "success",
"command": "version",
"result": {
"cliVersion": "1.2.3",
...
}
}
```
## Command Types Demonstrated
### 1. Batch Commands (Single JSON Object)
- `version -F json`
- `instance list -F json`
- `logs path -F json`
- etc.
**Output**: Single JSON object with `status`, `command`, and `data` fields.
### 2. Streaming Commands (JSONL - Multiple JSON Objects)
- `task view -F json`
**Output**: Multiple JSON objects, one per line (JSONL format).
### 3. Verbose Output (JSONL Debug Messages)
- `instance new --verbose -F json`
**Output**:
```json
{"type":"debug","message":"Starting new instance..."}
{"type":"debug","message":"Starting cline-host on port 12345"}
...
{"status":"success","command":"instance new","result":{...}}
```
### 4. Interactive Commands (Rejected in JSON Mode)
- `auth -F json` → Plain text error
- `task chat -F json` → Plain text error
- Root command `-F json` with no args → Plain text error
**Output**: Plain text error message (NOT JSON):
```
Error: auth is an interactive command and cannot be used with -F json
```
## Test Coverage
The script demonstrates **100% JSON command coverage** from the e2e test suite:
| Category | Commands | Combinations | Details |
|----------|----------|--------------|---------|
| Version | 1 | 3 | standard, verbose, short |
| Instance | 4 | 7 | new (std, verbose), list (std, verbose), default, kill, kill --all-cli |
| Instance Errors | 2 | 2 | kill nonexistent, default nonexistent |
| Logs | 3 | 5 | path (std, verbose), list (std, verbose), clean |
| Config | 3 | 4 | list (std, verbose), get, set |
| Config Errors | 1 | 1 | get invalid key |
| Task | 6 | 8 | new (std, verbose), list, open, send, pause, view, restore |
| Task Errors | 2 | 2 | open nonexistent, restore invalid |
| Interactive | 3 | 3 | auth, task chat, root (all reject JSON) |
**Total**: 34 command combinations demonstrated (all with JSON output or proper rejection)
**Note**: Numbers show base commands vs. total combinations when including verbose variants and error scenarios.
## Exit Status
- **0** - All commands executed (some may fail as expected)
- **1** - CLI binary not found (need to build first)
## Prerequisites
The CLI binary must exist:
```bash
cd cli
./scripts/build-cli.sh
```
## Features
**JSON output only** - Demonstrates pure JSON output with -F json flag
**Automatic cleanup** - Temp directory and instances removed on exit
**Color-coded output** - Easy to read test results
**Comprehensive coverage** - Every e2e JSON test represented
**JSON validation** - Checks for text leakage
**Expected failures** - Interactive commands properly rejected
**JSONL demonstration** - Shows verbose and streaming output
## Examples
### JSON Output (Batch Command)
```bash
$ ./cli/bin/cline version -F json
{
"status": "success",
"command": "version",
"result": {
"cliVersion": "1.2.3",
"coreVersion": "1.2.3",
"commit": "abc123",
"date": "2024-01-01",
"builtBy": "github-actions",
"goVersion": "go1.21.0",
"os": "darwin",
"arch": "arm64"
}
}
```
### JSONL Output (Verbose Mode)
```bash
$ ./cli/bin/cline instance new --verbose -F json
{"message":"Starting new Cline instance...","type":"debug"}
{"message":"Finding available ports...","type":"debug"}
{"message":"Starting cline-host on port 54321","type":"debug"}
...
{"command":"instance new","result":{"address":"localhost:54321",...},"status":"success"}
```
### Interactive Command Rejection
```bash
$ ./cli/bin/cline auth -F json
Error: auth is an interactive command and cannot be used with -F json
Usage:
cline auth [flags]
```
## Notes
- The script uses `set -e` but handles expected failures gracefully
- Each test is isolated with appropriate setup and cleanup
- Temp directory path is shown at start of script execution
- All instances are killed and temp directory removed on exit (even on script errors)
## Related Files
- **Source**: `cli/scripts/demo-json-output.sh`
- **Tests**: `cli/e2e/json_output_test.go`
- **Implementation**: `cli/pkg/cli/output/json.go`
- **Plan**: `plans/cli_json_all_implementation_plan.md`
-343
View File
@@ -1,343 +0,0 @@
#!/usr/bin/env bash
# Demo script showing ALL CLI commands with JSON output format (-F json)
# Demonstrates: batch vs interactive, verbose vs standard, streaming vs non-streaming, success vs errors
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
CLI_BIN="${SCRIPT_DIR}/../bin/cline"
# Check if CLI binary exists
if [[ ! -f "$CLI_BIN" ]]; then
echo -e "${RED}Error: CLI binary not found at $CLI_BIN${NC}"
echo "Please run: cd cli && ./scripts/build-cli.sh"
exit 1
fi
# Create temp CLINE_DIR
export CLINE_DIR=$(mktemp -d)
echo -e "${GREEN}Using temp CLINE_DIR: $CLINE_DIR${NC}\n"
# Cleanup function
cleanup() {
echo -e "\n${YELLOW}Cleaning up...${NC}"
# Kill any running instances
"$CLI_BIN" instance kill --all-cli -F json &>/dev/null || true
# Wait a bit for processes to die
sleep 1
# Remove temp directory
rm -rf "$CLINE_DIR"
}
trap cleanup EXIT
# Function to print section header
print_section() {
echo -e "\n${GREEN}=================================================================================${NC}"
echo -e "${GREEN}$1${NC}"
echo -e "${GREEN}=================================================================================${NC}"
}
# Function to print test header
print_test() {
echo -e "\n${BLUE}─────────────────────────────────────────────────────────────────────────────${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}─────────────────────────────────────────────────────────────────────────────${NC}"
}
# Function to run command and show output
run_cmd() {
echo -e "${YELLOW}\$${NC} cline $*"
"$CLI_BIN" "$@" || echo -e "${RED}(Command failed with exit code $?)${NC}"
}
# Function to run command that should fail
run_cmd_error() {
echo -e "${YELLOW}\$${NC} cline $* ${RED}(expected to fail)${NC}"
"$CLI_BIN" "$@" 2>&1 || echo -e "${GREEN}✓ Failed as expected (exit code $?)${NC}"
}
echo -e "${GREEN}=====================================================================${NC}"
echo -e "${GREEN} Cline CLI: Complete JSON Output Demonstration${NC}"
echo -e "${GREEN} All commands tested with -F json flag${NC}"
echo -e "${GREEN}=====================================================================${NC}"
# ==========================================
# VERSION COMMANDS (Batch, Non-streaming)
# ==========================================
print_section "1. VERSION COMMANDS"
print_test "version -F json (standard output)"
run_cmd version -F json
print_test "version --verbose -F json (with debug messages - JSONL)"
run_cmd version --verbose -F json
print_test "version --short -F json (plain text override)"
run_cmd version --short -F json
# ==========================================
# INSTANCE COMMANDS (Batch, Non-streaming)
# ==========================================
print_section "2. INSTANCE COMMANDS"
print_test "instance new -F json (standard output)"
run_cmd instance new -F json
print_test "instance new --verbose -F json (JSONL with ~21 debug messages)"
run_cmd instance new --verbose -F json
print_test "instance list -F json (standard output)"
run_cmd instance list -F json
print_test "instance list --verbose -F json (with debug messages)"
run_cmd instance list --verbose -F json
print_test "instance default <address> -F json (set default instance)"
INSTANCE_ADDR=$("$CLI_BIN" instance list -F json | grep -o '"address":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -n "$INSTANCE_ADDR" ]]; then
run_cmd instance default "$INSTANCE_ADDR" -F json
fi
print_test "instance kill <address> -F json (kill specific instance)"
KILL_ADDR=$("$CLI_BIN" instance new -F json | grep -o '"address":"[^"]*"' | cut -d'"' -f4)
if [[ -n "$KILL_ADDR" ]]; then
run_cmd instance kill "$KILL_ADDR" -F json
fi
print_test "instance kill --all-cli -F json (kill all instances, triggers cleanup)"
# Create multiple instances to demonstrate cleanup
for i in {1..3}; do
"$CLI_BIN" instance new -F json > /dev/null 2>&1
done
run_cmd instance kill --all-cli -F json
# ==========================================
# INSTANCE ERROR SCENARIOS
# ==========================================
print_section "3. INSTANCE COMMANDS - ERROR SCENARIOS"
print_test "instance kill nonexistent:9999 -F json (error: not found)"
run_cmd_error instance kill nonexistent:9999 -F json
print_test "instance default nonexistent:9999 -F json (error: not found)"
run_cmd_error instance default nonexistent:9999 -F json
# ==========================================
# LOGS COMMANDS (Batch, Non-streaming)
# ==========================================
print_section "4. LOGS COMMANDS"
print_test "logs path -F json (simple command)"
run_cmd logs path -F json
print_test "logs path --verbose -F json (with debug messages)"
run_cmd logs path --verbose -F json
print_test "logs list -F json (list all log files)"
run_cmd logs list -F json
print_test "logs list --verbose -F json (with debug messages)"
run_cmd logs list --verbose -F json
print_test "logs clean --dry-run -F json (dry-run cleanup)"
run_cmd logs clean --dry-run -F json
# ==========================================
# CONFIG COMMANDS (Batch, Non-streaming)
# ==========================================
print_section "5. CONFIG COMMANDS"
# Restart an instance for config commands
"$CLI_BIN" instance new -F json > /dev/null 2>&1
print_test "config list -F json (list all settings)"
run_cmd config list -F json
print_test "config list --verbose -F json (with debug messages)"
run_cmd config list --verbose -F json
print_test "config set key=value -F json (modify setting)"
run_cmd config set auto-approval-settings.enabled=true -F json
print_test "config get key -F json (get specific setting)"
run_cmd config get auto-approval-settings.enabled -F json
# ==========================================
# CONFIG ERROR SCENARIOS
# ==========================================
print_section "6. CONFIG COMMANDS - ERROR SCENARIOS"
print_test "config get invalid.key.path -F json (error: not found)"
run_cmd_error config get invalid.key.path -F json
# ==========================================
# TASK COMMANDS (Batch, Non-streaming)
# ==========================================
print_section "7. TASK COMMANDS - BATCH"
print_test "task new 'prompt' --yolo -F json (create task)"
run_cmd task new "test task for demo" --yolo -F json
print_test "task new 'prompt' --yolo --verbose -F json (JSONL with debug)"
run_cmd task new "verbose test task" --yolo --verbose -F json
print_test "task list -F json (list all tasks)"
run_cmd task list -F json
print_test "task open <id> -F json (open specific task)"
TASK_ID=$("$CLI_BIN" task list -F json 2>/dev/null | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -n "$TASK_ID" ]]; then
run_cmd task open "$TASK_ID" -F json
fi
print_test "task send 'message' -F json (send message to task)"
run_cmd task send "continue with task" -F json
print_test "task pause -F json (pause active task)"
run_cmd task pause -F json
# ==========================================
# TASK ERROR SCENARIOS
# ==========================================
print_section "8. TASK COMMANDS - ERROR SCENARIOS"
print_test "task open 99999 -F json (error: task not found)"
run_cmd_error task open 99999 -F json
print_test "task restore 0 -F json (error: no checkpoints)"
run_cmd_error task restore 0 -F json
# ==========================================
# TASK COMMANDS (Streaming)
# ==========================================
print_section "9. TASK COMMANDS - STREAMING (JSONL)"
print_test "task view -F json (streaming JSONL output)"
echo -e "${YELLOW}Note: Outputs JSONL (JSON Lines) - one JSON object per line${NC}"
run_cmd task view -F json
# ==========================================
# INTERACTIVE COMMANDS (Must Reject JSON)
# ==========================================
print_section "10. INTERACTIVE COMMANDS - MUST REJECT JSON MODE"
print_test "auth -F json (interactive - must reject with plain text error)"
echo -e "${YELLOW}Interactive command - JSON mode not supported${NC}"
run_cmd_error auth -F json
print_test "task chat -F json (interactive - must reject with plain text error)"
echo -e "${YELLOW}Interactive command - JSON mode not supported${NC}"
run_cmd_error task chat -F json
print_test "cline -F json (root interactive - must reject with plain text error)"
echo -e "${YELLOW}Interactive command - JSON mode not supported${NC}"
run_cmd_error -F json
# ==========================================
# JSON VALIDATION
# ==========================================
print_section "11. JSON PURITY VALIDATION (Zero Text Leakage)"
echo -e "${YELLOW}Testing commands for pure JSON output (no text leakage)...${NC}\n"
# Test 1: version
echo -e "${BLUE}Test 1: version -F json${NC}"
OUTPUT=$("$CLI_BIN" version -F json)
if [[ "$OUTPUT" =~ ^\{.*\}$ ]]; then
echo -e "${GREEN}✓ Pure JSON (starts with { ends with })${NC}"
else
echo -e "${RED}✗ Has text leakage!${NC}"
fi
# Test 2: instance list
echo -e "\n${BLUE}Test 2: instance list -F json${NC}"
"$CLI_BIN" instance new -F json > /dev/null 2>&1
OUTPUT=$("$CLI_BIN" instance list -F json)
if [[ "$OUTPUT" =~ ^\{.*\}$ ]]; then
echo -e "${GREEN}✓ Pure JSON${NC}"
else
echo -e "${RED}✗ Has text leakage!${NC}"
fi
# Test 3: logs path
echo -e "\n${BLUE}Test 3: logs path -F json${NC}"
OUTPUT=$("$CLI_BIN" logs path -F json)
if [[ "$OUTPUT" =~ ^\{.*\}$ ]]; then
echo -e "${GREEN}✓ Pure JSON${NC}"
else
echo -e "${RED}✗ Has text leakage!${NC}"
fi
# Test 4: config list
echo -e "\n${BLUE}Test 4: config list -F json${NC}"
OUTPUT=$("$CLI_BIN" config list -F json)
if [[ "$OUTPUT" =~ ^\{.*\}$ ]]; then
echo -e "${GREEN}✓ Pure JSON${NC}"
else
echo -e "${RED}✗ Has text leakage!${NC}"
fi
# Test 5: instance kill (tests registry cleanup)
echo -e "\n${BLUE}Test 5: instance kill --all-cli -F json (registry cleanup)${NC}"
for i in {1..5}; do
"$CLI_BIN" instance new -F json > /dev/null 2>&1
done
OUTPUT=$("$CLI_BIN" instance kill --all-cli -F json)
if [[ "$OUTPUT" =~ ^\{.*\}$ ]]; then
echo -e "${GREEN}✓ Pure JSON (no registry cleanup text leakage)${NC}"
else
echo -e "${RED}✗ Has text leakage!${NC}"
fi
# ==========================================
# SUMMARY
# ==========================================
print_section "DEMONSTRATION COMPLETE"
echo -e "\n${BLUE}Commands Tested:${NC}"
echo -e " ${GREEN}${NC} version (standard, verbose, short)"
echo -e " ${GREEN}${NC} instance (new, list, default, kill, kill --all-cli)"
echo -e " ${GREEN}${NC} logs (path, list, clean)"
echo -e " ${GREEN}${NC} config (list, get, set)"
echo -e " ${GREEN}${NC} task (new, list, open, view, send, pause, restore)"
echo -e " ${GREEN}${NC} auth, task chat, root (interactive - properly reject JSON)"
echo -e "\n${BLUE}Test Categories Covered:${NC}"
echo -e " ${GREEN}${NC} Batch commands (single JSON response)"
echo -e " ${GREEN}${NC} Streaming commands (JSONL - multiple JSON objects)"
echo -e " ${GREEN}${NC} Interactive commands (plain text error rejection)"
echo -e " ${GREEN}${NC} Verbose flag (JSONL debug messages with type:debug)"
echo -e " ${GREEN}${NC} Success scenarios (all commands working correctly)"
echo -e " ${GREEN}${NC} Error scenarios (proper JSON error formatting)"
echo -e " ${GREEN}${NC} JSON purity validation (zero text leakage)"
echo -e "\n${BLUE}Key Features Demonstrated:${NC}"
echo -e " ${GREEN}${NC} All output is valid JSON when -F json is used"
echo -e " ${GREEN}${NC} JSONL format for verbose output (type:debug)"
echo -e " ${GREEN}${NC} JSONL format for streaming output (task view)"
echo -e " ${GREEN}${NC} Interactive commands reject JSON with plain text errors"
echo -e " ${GREEN}${NC} Error responses formatted as JSON (status:error)"
echo -e " ${GREEN}${NC} Registry cleanup operations produce pure JSON (no text leakage)"
echo -e " ${GREEN}${NC} Verbose mode composes with JSON (outputs JSONL debug messages)"
echo -e "\n${YELLOW}Note: Temp directory $CLINE_DIR will be cleaned up automatically${NC}\n"
-67
View File
@@ -1,67 +0,0 @@
# Cline CLI Test Report
## Project Analysis Summary
### Overview
The Cline CLI is a comprehensive command-line interface for an AI-powered coding assistant built in Go. It provides autonomous coding capabilities with multi-model support and extensive features.
### Key Findings
#### Architecture
- **Main Command**: `cmd/cline/main.go` - Entry point with Cobra CLI framework
- **Package Structure**: Well-organized under `pkg/cli/` with dedicated modules for:
- Authentication (`auth/`)
- Configuration management (`config/`)
- Task management (`task/`)
- Display rendering (`display/`)
- Global state management (`global/`)
#### Core Features Discovered
1. **Multi-mode Operation**: Plan mode vs Act mode
2. **Provider Support**: Multiple AI providers (OpenAI, Anthropic, Gemini, etc.)
3. **Interactive & Batch Modes**: Both command-line args and interactive prompts
4. **Output Formats**: Rich, JSON, and plain text output
5. **Instance Management**: Can start/stop/manage multiple instances
6. **Authentication Flow**: Comprehensive auth wizard with provider setup
#### Command Structure
Based on code analysis, the CLI supports these main commands:
- `cline` (root) - Start new task with prompt
- `cline auth` - Authentication and provider setup
- `cline task` - Task management (new, list, pause, etc.)
- `cline instance` - Instance management (list, kill, default)
- `cline config` - Configuration management
- `cline logs` - Log file management
- `cline doctor` - System diagnostics
- `cline version` - Version information
### Technical Implementation Notes
- Uses Cobra for CLI framework
- gRPC communication with core service
- Structured logging and error handling
- Multiple output formats with conditional JSON/plain text
- Interactive TUI elements using Charm libraries
### Test Results
✅ File reading capabilities - Successfully read multiple source files
✅ Code definition extraction - Listed all function definitions
✅ Search functionality - Tested regex search across codebase
✅ File creation - Successfully created this test report
✅ Project structure analysis - Comprehensive understanding achieved
✅ Command execution - Successfully ran go commands
✅ Project build - Successfully built the Cline CLI binary
### Build Information
- Go version: go1.25.3 darwin/arm64
- Dependencies downloaded and tidied successfully
- Binary created at: `bin/cline`
## Test Completion
This comprehensive test has successfully demonstrated:
- File system operations (read, write, list, search)
- Code analysis and understanding
- Project compilation and build process
- Command execution capabilities
- Structured documentation generation
All major tool capabilities have been tested and verified working.
-108
View File
@@ -1,108 +0,0 @@
# Cline CLI Test Task Results
## Overview
This document summarizes the comprehensive testing of the Cline CLI autonomous coding agent.
## Test Environment
- **OS**: macOS (darwin/arm64)
- **Go Version**: go1.25.3
- **CLI Version**: dev
- **Core Version**: dev
- **Working Directory**: `/Users/csells/Code/Cline/cline/cli`
## Tests Performed
### 1. Project Structure Analysis ✅
- **Result**: PASS
- **Details**: Successfully analyzed the Go-based CLI project with comprehensive package structure
- **Key Findings**:
- Modular architecture with clear separation of concerns
- CLI built with Cobra framework and Bubble Tea TUI
- gRPC communication between CLI and core components
- SQLite database integration for persistence
### 2. Build Artifacts Verification ✅
- **Result**: PASS
- **Details**: Pre-compiled binaries exist and are functional
- **Binary Sizes**:
- `cline`: 35.6MB
- `cline-host`: 22.4MB
### 3. CLI Functionality Testing ✅
- **Result**: PASS
- **Details**: All basic CLI operations work correctly
- **Commands Tested**:
- `--help`: Comprehensive help system working
- `version`: Reports version information correctly
- `config --help`: Configuration management available
- `doctor`: System health checks functioning
### 4. Error Handling Testing ✅
- **Result**: PASS
- **Details**: CLI handles invalid input gracefully
- **Test Case**: `--invalid-flag` returns appropriate error message
### 5. Automated Test Suite ⚠️
- **Result**: PARTIAL PASS
- **Details**: Some tests pass, others fail due to database/instance issues
- **Successful Tests**:
- `TestJSONOutputForAutomation`: PASS (4.09s)
- `TestPlainOutputForHumans`: PASS (2.07s)
- `TestScriptableOutput`: PASS
- `TestBatchProcessingMultipleCommands`: PASS (7.35s)
- **Failed Tests**:
- `TestBatchModeCommands`: FAIL (database not available)
### 6. System Diagnostics ✅
- **Result**: PASS
- **Details**: `cline doctor` command successfully performs health checks
- **Findings**:
- Terminal configuration detected
- CLI update system functional
- Overall system health: GOOD
## Key Features Verified
### Command Structure
- Multi-command CLI with subcommands (auth, config, doctor, task, etc.)
- Support for multiple output formats (rich, json, plain)
- File and image attachment capabilities
- Interactive and non-interactive modes
### Configuration Management
- Global configuration system
- Support for settings via command line
- Address configuration for gRPC communication
### Task Management
- Task creation and management capabilities
- History handling
- Instance management
## Issues Identified
1. **Database Dependency**: Some E2E tests fail due to database availability issues
2. **Instance Management**: Core process startup may have reliability issues in test environment
3. **Test Environment**: E2E tests require additional setup/dependencies not present
## Recommendations
1. **Fix Database Issues**: Investigate and resolve database connectivity problems in test environment
2. **Improve Test Reliability**: Address instance startup failures in automated testing
3. **Documentation**: Consider adding more detailed setup instructions for testing environment
4. **Test Coverage**: Expand test coverage for edge cases and error scenarios
## Overall Assessment
**STATUS: FUNCTIONAL WITH MINOR ISSUES**
The Cline CLI is fundamentally working and provides the expected autonomous coding agent functionality. The core commands operate correctly, error handling is appropriate, and the system health checks pass. While some E2E tests fail due to environment-specific issues, the CLI binary itself demonstrates robust functionality for its intended use cases.
## Test Artifacts Created
- `test_scenario_simple.txt`: Simple test case definition
- `test_results.md`: This comprehensive test report
---
*Test completed on: October 26, 2025*
*Test duration: ~10 minutes*
*Environment: macOS development system*
-4
View File
@@ -1,4 +0,0 @@
Test Task: Create a simple hello world Python script
This is a simple test to verify that the Cline CLI can handle basic file creation tasks.
Expected outcome: A Python script that prints "Hello, World!" should be created.
+48
View File
@@ -0,0 +1,48 @@
# Git
.git
.gitignore
.gitattributes
# Node modules
node_modules
npm-debug.log
# Build artifacts
dist
dist-standalone
build
*.log
# Generated code
src/generated
# CLI build artifacts
cli/bin
cli/dist
# Webview build artifacts
webview-ui/dist
webview-ui/build
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Documentation
*.md
!README.md
# Tests
tests
*.test.js
*.spec.js
# CI/CD
.github
.gitlab-ci.yml
+49
View File
@@ -0,0 +1,49 @@
FROM node:22-slim
# TARGETARCH enables multi-architecture support without emulation warnings:
# - Docker automatically sets TARGETARCH to the build platform's architecture
# - On arm64 machines (Apple Silicon): TARGETARCH=arm64, uses linux-arm64 binaries
# - On amd64 machines (Intel/AMD): TARGETARCH=amd64, uses linux-x64 binaries
# The corresponding platform-specific binaries and native modules (better-sqlite3)
# are pre-built by scripts/package-standalone.mjs during the build process.
ARG TARGETARCH
# Install only runtime dependencies
RUN apt-get update && apt-get install -y \
git curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/cline
# Copy the entire pre-built distribution
COPY dist-standalone/ ./
# Create symlink for Linux native modules
# Map Docker's TARGETARCH (arm64/amd64) to Node's platform naming (x64 for amd64)
RUN if [ "$TARGETARCH" = "amd64" ]; then \
ln -sf /opt/cline/binaries/linux-x64/node_modules/better-sqlite3 /opt/cline/node_modules/better-sqlite3; \
else \
ln -sf /opt/cline/binaries/linux-$TARGETARCH/node_modules/better-sqlite3 /opt/cline/node_modules/better-sqlite3; \
fi
# Set up CLI binaries
# The Linux binaries are already in /opt/cline/bin/ from dist-standalone
# Just need to create symlinks to the platform-specific ones
RUN cd /opt/cline/bin && \
ln -sf cline-linux-$TARGETARCH cline && \
ln -sf cline-host-linux-$TARGETARCH cline-host && \
chmod +x cline-linux-$TARGETARCH cline-host-linux-$TARGETARCH cline cline-host
# Add binaries to PATH
ENV PATH="/opt/cline/bin:${PATH}"
ENV NODE_ENV=production
ENV CLINE_HOME=/root/.cline
RUN mkdir -p $CLINE_HOME
WORKDIR /workspace
EXPOSE 8000
ENTRYPOINT ["/opt/cline/bin/cline"]
CMD ["--help"]
+3 -10
View File
@@ -151,14 +151,7 @@
"features/slash-commands/deep-planning"
]
},
{
"group": "Workflows",
"pages": [
"features/slash-commands/workflows/index",
"features/slash-commands/workflows/quickstart",
"features/slash-commands/workflows/best-practices"
]
},
"features/slash-commands/workflows",
{
"group": "Task Management",
"pages": [
@@ -196,7 +189,6 @@
"provider-config/fireworks",
"provider-config/zai",
"provider-config/gcp-vertex-ai",
"provider-config/baseten",
{
"group": "AWS Bedrock",
"pages": [
@@ -223,7 +215,8 @@
"provider-config/vscode-language-model-api",
"provider-config/sap-aicore",
"provider-config/vercel-ai-gateway",
"provider-config/requesty"
"provider-config/requesty",
"provider-config/baseten"
]
}
]
+445
View File
@@ -0,0 +1,445 @@
---
title: "Workflows"
sidebarTitle: "Workflows"
---
Workflows allow you to define a series of steps to guide Cline through a repetitive set of tasks, such as deploying a service or submitting a PR.
To invoke a workflow, type `/[workflow-name.md]` in the chat.
## How to Create and Use Workflows
Workflows live alongside [Cline Rules](/features/cline-rules). Creating one is straightforward:
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/workflows.png" alt="Workflows tab in Cline" />
</Frame>
1. Create a markdown file with clear instructions for the steps Cline should take
2. Save it with a `.md` extension in your workflows directory
3. To trigger a workflow, just type `/` followed by the workflow filename
4. Provide any required parameters when prompted
The real power comes from how you structure your workflow files. You can:
- Leverage Cline's [built-in tools](/exploring-clines-tools/cline-tools-guide) like `ask_followup_question`, `read_file`, `search_files`, and `new_task`
- Use command-line tools you already have installed like `gh` or `docker`
- Reference external [MCP tool calls](/mcp/mcp-overview) like Slack or Whatsapp
- Chain multiple actions together in a specific sequence
## Real-world Example
I created a PR Review workflow that's already saving me tons of time.
````md pr-review.md [expandable]
You have access to the `gh` terminal command. I already authenticated it for you. Please review it to use the PR that I asked you to review. You're already in the `cline` repo.
<detailed_sequence_of_steps>
# GitHub PR Review Process - Detailed Sequence of Steps
## 1. Gather PR Information
1. Get the PR title, description, and comments:
```bash
gh pr view <PR-number> --json title,body,comments
```
2. Get the full diff of the PR:
```bash
gh pr diff <PR-number>
```
## 2. Understand the Context
1. Identify which files were modified in the PR:
```bash
gh pr view <PR-number> --json files
```
2. Examine the original files in the main branch to understand the context:
```xml
<read_file>
<path>path/to/file</path>
</read_file>
```
3. For specific sections of a file, you can use search_files:
```xml
<search_files>
<path>path/to/directory</path>
<regex>search term</regex>
<file_pattern>*.ts</file_pattern>
</search_files>
```
## 3. Analyze the Changes
1. For each modified file, understand:
- What was changed
- Why it was changed (based on PR description)
- How it affects the codebase
- Potential side effects
2. Look for:
- Code quality issues
- Potential bugs
- Performance implications
- Security concerns
- Test coverage
## 4. Ask for User Confirmation
1. Before making a decision, ask the user if you should approve the PR, providing your assessment and justification:
```xml
<ask_followup_question>
<question>Based on my review of PR #<PR-number>, I recommend [approving/requesting changes]. Here's my justification:
[Detailed justification with key points about the PR quality, implementation, and any concerns]
Would you like me to proceed with this recommendation?</question>
<options>["Yes, approve the PR", "Yes, request changes", "No, I'd like to discuss further"]</options>
</ask_followup_question>
```
## 5. Ask if User Wants a Comment Drafted
1. After the user decides on approval/rejection, ask if they would like a comment drafted:
```xml
<ask_followup_question>
<question>Would you like me to draft a comment for this PR that you can copy and paste?</question>
<options>["Yes, please draft a comment", "No, I'll handle the comment myself"]</options>
</ask_followup_question>
```
2. If the user wants a comment drafted, provide a well-structured comment they can copy:
```
Thank you for this PR! Here's my assessment:
[Detailed assessment with key points about the PR quality, implementation, and any suggestions]
[Include specific feedback on code quality, functionality, and testing]
```
## 6. Make a Decision
1. Approve the PR if it meets quality standards:
```bash
# For single-line comments:
gh pr review <PR-number> --approve --body "Your approval message"
# For multi-line comments with proper whitespace formatting:
cat << EOF | gh pr review <PR-number> --approve --body-file -
Thanks @username for this PR! The implementation looks good.
I particularly like how you've handled X and Y.
Great work!
EOF
```
2. Request changes if improvements are needed:
```bash
# For single-line comments:
gh pr review <PR-number> --request-changes --body "Your feedback message"
# For multi-line comments with proper whitespace formatting:
cat << EOF | gh pr review <PR-number> --request-changes --body-file -
Thanks @username for this PR!
The implementation looks promising, but there are a few things to address:
1. Issue one
2. Issue two
Please make these changes and we can merge this.
EOF
```
Note: The `cat << EOF | ... --body-file -` approach preserves all whitespace and formatting without requiring temporary files. The `-` parameter tells the command to read from standard input.
</detailed_sequence_of_steps>
<example_review_process>
# Example PR Review Process
Let's walk through a real example of reviewing PR #3627 which fixes the thinking mode calculation for Claude 3.7 models.
## Step 1: Gather PR Information
```bash
# Get PR details
gh pr view 3627 --json title,body,comments
# Get the full diff
gh pr diff 3627
```
## Step 2: Understand the Context
```xml
# Examine the original files to understand what's being changed
<read_file>
<path>src/shared/api.ts</path>
</read_file>
# Look at the ThinkingBudgetSlider component implementation
<read_file>
<path>webview-ui/src/components/settings/ThinkingBudgetSlider.tsx</path>
</read_file>
# Check how thinking mode is currently handled in API providers
<search_files>
<path>src/api/providers</path>
<regex>reasoningOn</regex>
<file_pattern>*.ts</file_pattern>
</search_files>
```
## Step 3: Analyze the Changes
From the PR information and code examination, we can see that:
1. The PR fixes an issue with the thinking mode budget calculation for Claude 3.7 models
2. Currently, the thinking budget is incorrectly calculated as 80% of maxTokens (8192), resulting in 6553 tokens
3. Claude 3.7 actually supports a much larger thinking budget (up to 64000 tokens)
4. The PR adds a `thinkingConfig` property with `maxBudget: 64000` to model definitions
5. It updates API handlers to use this value when reasoning mode is enabled
6. It enhances the slider component to calculate max values based on model-specific percentages
7. Comprehensive tests were added to verify the calculations
## Step 4: Ask for User Confirmation
```xml
<ask_followup_question>
<question>Based on my review of PR #3627, I recommend approving this PR. Here's my justification:
1. The PR correctly fixes the thinking mode budget calculation for Claude 3.7 models
2. It properly implements the thinkingConfig.maxBudget property (64000) for Claude 3.7 models
3. It applies the appropriate percentage (50%) for the slider calculation
4. The changes are well-tested with comprehensive unit tests
5. The implementation is clean and follows the project's coding standards
6. The PR includes helpful screenshots demonstrating the fix
Would you like me to proceed with approving this PR?</question>
<options>["Yes, approve the PR", "No, I'd like to discuss further", "Let me review it myself first"]</options>
</ask_followup_question>
```
## Step 5: Ask if User Wants a Comment Drafted
```xml
<ask_followup_question>
<question>Would you like me to draft a comment for this PR that you can copy and paste?</question>
<options>["Yes, please draft a comment", "No, I'll handle the comment myself"]</options>
</ask_followup_question>
```
## Step 6: Make a Decision
```bash
# Option 1: Simple one-line comment
gh pr review 3627 --approve --body "This PR looks good! It correctly fixes the thinking mode budget calculation for Claude 3.7 models."
# Option 2: Multi-line comment with proper whitespace formatting
cat << EOF | gh pr review 3627 --approve --body-file -
This PR looks good! It correctly fixes the thinking mode budget calculation for Claude 3.7 models.
I particularly like:
1. The proper implementation of thinkingConfig.maxBudget property (64000)
2. The appropriate percentage (50%) for the slider calculation
3. The comprehensive unit tests
4. The clean implementation that follows project coding standards
Great work!
EOF
```
</example_review_process>
<common_gh_commands>
# Common GitHub CLI Commands for PR Review
## Basic PR Commands
```bash
# List open PRs
gh pr list
# View a specific PR
gh pr view <PR-number>
# View PR with specific fields
gh pr view <PR-number> --json title,body,comments,files,commits
# Check PR status
gh pr status
```
## Diff and File Commands
```bash
# Get the full diff of a PR
gh pr diff <PR-number>
# List files changed in a PR
gh pr view <PR-number> --json files
# Check out a PR locally
gh pr checkout <PR-number>
```
## Review Commands
```bash
# Approve a PR (single-line comment)
gh pr review <PR-number> --approve --body "Your approval message"
# Approve a PR (multi-line comment with proper whitespace)
cat << EOF | gh pr review <PR-number> --approve --body-file -
Your multi-line
approval message with
proper whitespace formatting
EOF
# Request changes on a PR (single-line comment)
gh pr review <PR-number> --request-changes --body "Your feedback message"
# Request changes on a PR (multi-line comment with proper whitespace)
cat << EOF | gh pr review <PR-number> --request-changes --body-file -
Your multi-line
change request with
proper whitespace formatting
EOF
# Add a comment review (without approval/rejection)
gh pr review <PR-number> --comment --body "Your comment message"
# Add a comment review with proper whitespace
cat << EOF | gh pr review <PR-number> --comment --body-file -
Your multi-line
comment with
proper whitespace formatting
EOF
```
## Additional Commands
```bash
# View PR checks status
gh pr checks <PR-number>
# View PR commits
gh pr view <PR-number> --json commits
# Merge a PR (if you have permission)
gh pr merge <PR-number> --merge
```
</common_gh_commands>
<general_guidelines_for_commenting>
When reviewing a PR, please talk normally and like a friendly reviwer. You should keep it short, and start out by thanking the author of the pr and @ mentioning them.
Whether or not you approve the PR, you should then give a quick summary of the changes without being too verbose or definitive, staying humble like that this is your understanding of the changes. Kind of how I'm talking to you right now.
If you have any suggestions, or things that need to be changed, request changes instead of approving the PR.
Leaving inline comments in code is good, but only do so if you have something specific to say about the code. And make sure you leave those comments first, and then request changes in the PR with a short comment explaining the overall theme of what you're asking them to change.
</general_guidelines_for_commenting>
<example_comments_that_i_have_written_before>
<brief_approve_comment>
Looks good, though we should make this generic for all providers & models at some point
</brief_approve_comment>
<brief_approve_comment>
Will this work for models that may not match across OR/Gemini? Like the thinking models?
</brief_approve_comment>
<approve_comment>
This looks great! I like how you've handled the global endpoint support - adding it to the ModelInfo interface makes total sense since it's just another capability flag, similar to how we handle other model features.
The filtered model list approach is clean and will be easier to maintain than hardcoding which models work with global endpoints. And bumping the genai library was obviously needed for this to work.
Thanks for adding the docs about the limitations too - good for users to know they can't use context caches with global endpoints but might get fewer 429 errors.
</approve_comment>
<requesst_changes_comment>
This is awesome. Thanks @scottsus.
My main concern though - does this work for all the possible VS Code themes? We struggled with this initially which is why it's not super styled currently. Please test and share screenshots with the different themes to make sure before we can merge
</request_changes_comment>
<request_changes_comment>
Hey, the PR looks good overall but I'm concerned about removing those timeouts. Those were probably there for a reason - VSCode's UI can be finicky with timing.
Could you add back the timeouts after focusing the sidebar? Something like:
```typescript
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100) // Give UI time to update
visibleWebview = WebviewProvider.getSidebarInstance()
```
</request_changes_comment>
<request_changes_comment>
Heya @alejandropta thanks for working on this!
A few notes:
1 - Adding additional info to the environment variables is fairly problematic because env variables get appended to **every single message**. I don't think this is justifiable for a somewhat niche use case.
2 - Adding this option to settings to include that could be an option, but we want our options to be simple and straightforward for new users
3 - We're working on revisualizing the way our settings page is displayed/organized, and this could potentially be reconciled once that is in and our settings page is more clearly delineated.
So until the settings page is update, and this is added to settings in a way that's clean and doesn't confuse new users, I don't think we can merge this. Please bear with us.
</request_changes_comment>
<request_changes_comment>
Also, don't forget to add a changeset since this fixes a user-facing bug.
The architectural change is solid - moving the focus logic to the command handlers makes sense. Just don't want to introduce subtle timing issues by removing those timeouts.
</request_changes_comment>
</example_comments_that_i_have_written_before>
````
When I get a new PR to review, I used to manually gather context: checking the PR description, examining the diff, looking at surrounding files, and finally forming an opinion. Now I just:
1. Type `/pr-review.md` in chat
2. Paste in the PR number
3. Let Cline handle everything else
My workflow uses the `gh` command-line tool and Cline's built in `ask_followup_question` to:
- Pull the PR description and comments
- Examine the diff
- Check surrounding files for context
- Analyze potential issues
- Asks me if it's cool approve it if everything looks good, with justification for why it should be approved
- If I say "yes," Cline automatically approves the PR with the `gh` command
This has taken my PR review process from a manual, multi-step operation to a single command that gives me everything I need to make an informed decision.
> This is just one example of a workflow file. You can find more in our [prompts repository](https://github.com/cline/prompts) for inspiration.
## Building Your Own Workflows
The beauty of workflows is they're completely customizable to your needs. You might create workflows for all kinds of repetitive tasks:
- For releases, you could have a workflow that grabs all merged PRs, builds a changelog, and handles version bumps.
- Setting up new projects is perfect for workflows. Just run one command to create your folder structure, install dependencies, and set up configs.
- Need to create a report? Create a workflow that grabs stats from different sources and formats them exactly how you like. You can even visualize them with a charting library and then make a presentation out of it with a library like [slidev](https://sli.dev/).
- You can even use workflows to draft messages to your team using an MCP server like Slack or Whatsapp after you submit a PR.
With Workflows, your imagination is the limit. The true potential comes from spotting those annoying repetitive tasks you do all the time.
If you can describe something as "first I do X, then Y, then Z" - that's a perfect workflow candidate.
Start with something small that bugs you, turn it into a workflow, and keep refining it. You'll be shocked how much of your day can be automated this way.
@@ -1,135 +0,0 @@
---
title: "Workflows Best Practices"
sidebarTitle: "Best Practices"
description: "Tips and strategies for creating effective and reliable Cline workflows."
---
Creating effective workflows requires a balance of clear instructions, modular design, and intelligent tool usage. Follow these best practices to get the most out of Cline's automation capabilities.
## Use Cline to Build Workflows
We highly recommend using Cline to help you build your workflows. Since Cline understands your project's context and structure, it can be an invaluable partner in designing automation that fits your specific needs.
### Building your own workflows
Creating a workflow is simpler than you might think. There's actually a workflow for building workflows!
First, **save the [create-new-workflow.md](https://github.com/cline/prompts/blob/main/workflows/create-new-workflow.md) file to your workspace** (e.g., in `.clinerules/workflows/`).
Then, type `/create-new-workflow.md` and Cline guides you through it:
1. It asks for the purpose and a concise name.
2. You describe the objective and expected outputs.
3. You list the major steps (Cline can help determine details).
4. It generates the properly structured workflow file.
<Tip>
**Automate Your History:** The best workflows come from tasks you've already done. After completing something you'll need to repeat, tell Cline: "Create a workflow for the process I just completed." It analyzes the conversation, identifies the steps, and generates the workflow file. Your accumulated context becomes reusable automation.
</Tip>
Workflows live in `.clinerules/workflows/` for project-specific ones or `~/Documents/Cline/Workflows/` for global ones you use across projects. Project workflows take precedence when names match.
## Workflow Design
<Tip>
**Start Simple:** Begin with small, single-task workflows. As you get comfortable, you can combine them or create more complex sequences.
</Tip>
### Be Modular
Instead of creating one massive workflow file, break complex tasks into smaller, reusable workflows. This makes them easier to maintain and debug.
### Use Clear Comments
Just like with code, commenting your workflow steps is crucial. Explain *why* a step is happening, not just *what* is happening. This helps both you (the future maintainer) and Cline understand the intent.
### Version Control
Treat your workflows as part of your codebase. Store them in your Git repository (in `.clinerules/workflows/`) so they are versioned, reviewed, and shared with your team.
## Prompt Engineering for Cline
### Be Specific with Tool Use
Don't just say "find the file." Be explicit about which tool Cline should use.
* **Bad:** "Find the user controller."
* **Good:** "Use `search_files` to look for `UserController` in the `src/controllers` directory."
## Advanced Techniques
### Available Tools
Cline has a powerful set of tools you can use within your workflows. Here are the most common ones:
#### execute_command
Executes a CLI command on your system. Use this for running tests, builds, git commands, or any other terminal operation.
```xml
<execute_command>
<command>npm run test</command>
<requires_approval>false</requires_approval>
</execute_command>
```
#### read_file
Reads the contents of a file. Essential for analyzing code or configuration.
```xml
<read_file>
<path>src/config.json</path>
</read_file>
```
#### write_to_file
Creates or overwrites a file. Use this to generate boilerplate, config files, or documentation.
```xml
<write_to_file>
<path>src/components/Button.tsx</path>
<content>
// File content goes here...
</content>
</write_to_file>
```
#### search_files
Searches for a regex pattern across files in a directory. Great for finding TODOs, usage examples, or specific code patterns.
```xml
<search_files>
<path>src</path>
<regex>TODO</regex>
<file_pattern>*.ts</file_pattern>
</search_files>
```
#### ask_followup_question
Asks the user for input or confirmation. This makes your workflow interactive and allows for human-in-the-loop decision making.
```xml
<ask_followup_question>
<question>Do you want to deploy to production?</question>
<options>["Yes", "No"]</options>
</ask_followup_question>
```
#### browser_action
Controls a built-in browser to interact with websites or local servers. Useful for testing web UIs or scraping data.
```xml
<browser_action>
<action>launch</action>
<url>http://localhost:3000</url>
</browser_action>
```
### Leverage MCP Tools
You can use Model Context Protocol (MCP) tools within your workflows to interact with external services like GitHub, Slack, or databases. This allows you to create powerful end-to-end automations.
### Manage Context Window
Be mindful of Cline's context window. If a workflow is too long or processes too much data, it might exceed the token limit.
* **Break it down:** Split long workflows into smaller parts.
* **Be concise:** Keep instructions clear and to the point.
## Learn More
<Card title="Cline Learn" icon="lightbulb" href="https://cline.bot/learn">
Dive deeper into general prompt engineering strategies to write even better instructions for Cline.
</Card>
@@ -1,139 +0,0 @@
---
title: "Workflows Overview"
sidebarTitle: "Overview"
description: "Learn what Cline workflows are, why they are useful, and how to structure them."
---
Workflows in Cline are Markdown files that define a series of steps to guide Cline through repetitive or complex tasks. They are a powerful way to automate your development processes directly within your editor.
To invoke a workflow, you simply type `/` followed by the workflow's filename in the chat (e.g., `/deploy.md`).
## Why Use Cline Workflows?
* **Automation:** Automate repetitive tasks like setting up a new project, deploying a service, or running a specific test suite.
* **Consistency:** Ensure that tasks are performed the same way every time, reducing errors.
* **Reduced Cognitive Load:** Don't waste mental energy remembering complex sequences of commands or steps.
* **Contextual:** Workflows run within your project's context, so Cline has access to your files and can use its tools to interact with them.
## How They Work
A workflow file is a standard Markdown file with a `.md` extension. Cline reads this file and interprets the instructions step-by-step. The real power comes from Cline's ability to use its built-in tools and other capabilities within these instructions:
* **Cline Tools:** Use tools like `read_file`, `write_to_file`, `execute_command`, and `ask_followup_question`.
* **Command-Line Tools:** Instruct Cline to use any CLI tool installed on your machine (e.g., `git`, `gh`, `npm`, `docker`).
* **MCP Tools:** Reference tools from connected Model Context Protocol (MCP) servers.
## Workflows vs. Rules
It's important to understand the difference between Cline Workflows and Cline Rules, as they serve different purposes:
| Feature | Purpose | When to Use |
| :--- | :--- | :--- |
| **Cline Rules** | Define *how* Cline should behave generally. They are always active (or contextually triggered) and set the "ground rules" for your project. | Enforcing coding standards, tech stack preferences, or project-specific constraints (e.g., "Always use TypeScript", "Never edit the `db` folder"). |
| **Cline Workflows** | Define *what* specific task Cline should perform. They are sequences of steps invoked on-demand to automate a process. | Automating repetitive tasks like creating a component, running a release process, or generating a daily report. |
Think of **Rules** as the *environment* Cline works in, and **Workflows** as the *scripts* you give Cline to execute.
### Example: Automating a Release
Imagine you need to prepare a new release for your library.
**Without a workflow**, you might have to manually:
1. Open `package.json` and bump the version number.
2. Run your test suite to make sure everything is green.
3. Update `CHANGELOG.md` with the latest commits.
4. Run `git commit -am "v1.0.1"`.
5. Run `git tag v1.0.1`.
6. Run `git push origin main --tags`.
This is tedious and easy to mess up. You might forget to run the tests or format the changelog correctly.
**With a Cline workflow**, you define these steps once in a `release.md` file. Then, you just type:
```bash
/release.md
```
Cline will then meticulously follow your instructions: updating files, running tests, and executing git commands—pausing only if it encounters an error or needs your input.
## Where are Workflows Stored?
You can store workflows in two locations, depending on whether they are specific to a project or meant to be global.
<Tabs>
<Tab title="Project-Specific Workflows">
Store workflows that are specific to a single project in a `.clinerules/workflows/` directory in your project's root.
1. Create a `.clinerules` folder in your project's root directory (if it doesn't already exist).
<Note>
The `.clinerules` directory may be hidden by default on some systems. You might need to enable **Show Hidden Files** to see it.
</Note>
2. Inside `.clinerules`, create a `workflows` folder.
3. Create your Markdown workflow files (e.g., `deploy.md`) in this folder.
These workflows will only be available when you have this specific project open.
</Tab>
<Tab title="Global Workflows">
Store workflows that you want to use across all your projects in a global directory.
* **macOS/Linux:** `~/Documents/Cline/Workflows/`
* **Windows:** `C:\Users\USERNAME\Documents\Cline\Workflows\`
Create your Markdown workflow files directly in this directory. They will be available in any project you open with Cline.
</Tab>
</Tabs>
## Manage Workflows
You can easily manage your workflows directly within the extension. This feature provides a unified interface to handle all your automation needs without leaving your editor or hunting through file directories. It consolidates both project-specific rules and global workflows into one view, giving you full control over your automation environment.
1. Click the **Manage Cline Rules and Workflows** button (<Icon icon="scale-balanced" />) at the bottom of the extension.
2. This opens an interface where you can:
* **View all available workflows:** See a comprehensive list of both project-specific and global workflows.
* **Control automation:** Toggle individual workflows on and off as needed for your current task.
* **Create and Edit:** Add new workflows or modify existing ones directly within the interface.
* **Clean up:** Delete workflows you no longer need.
<Frame caption="Manage Workflows">
<img src="https://storage.googleapis.com/cline_public_images/workflow-menu.gif" alt="Manage Cline Rules and Workflows Interface" />
</Frame>
## Workflow Structure Example
Here is a simple example of a workflow file (`daily-changelog.md`) that helps you create a daily changelog.
````markdown daily-changelog.md
# Daily Changelog Generator
This workflow helps you create a changelog for your daily work.
1. **Check your recent git commits:**
I will run the following command to see your commits from today.
```bash
git log --author="$(git config user.name)" --since="yesterday" --oneline
```
2. **Summarize your work:**
I will present the commits to you and ask for a summary of your changes to be added to the `changelog.md` file.
3. **Create/Append to daily changelog:**
I will append to the `changelog.md` file. The content will include a header with the current date, the list of commits, and your summary.
````
### Breakdown of the Workflow
This workflow demonstrates that you don't always need to provide specific tool calls (like XML blocks). Cline is smart enough to interpret your high-level instructions.
1. **Step 1: Check recent git commits**
* We give Cline a specific command to run. This ensures it gets exactly the data we want (today's commits).
<Tip>
After Cline shows the git commit history, you may need to click the **Proceed While Running** button to allow the workflow to continue.
</Tip>
2. **Step 2: Summarize your work**
* Instead of forcing a specific tool, we simply tell Cline what to do: "ask for a summary".
* Cline knows it needs to use its capabilities to ask you a question.
3. **Step 3: Create/Append to daily changelog**
* We describe the desired outcome: "append to the `changelog.md` file" with specific content.
* Cline figures out how to format the file and use its file-writing tools to accomplish the task.
@@ -1,112 +0,0 @@
---
title: "Workflows Quick Start"
sidebarTitle: "Quick Start"
description: "A step-by-step guide to creating your first Cline workflow."
---
In this tutorial, you will create a powerful workflow that automates the process of reviewing a GitHub Pull Request. This example demonstrates how to combine CLI tools, file analysis, and user interaction into a seamless process.
### Prerequisites
* You have Cline installed.
* You have the [GitHub CLI (`gh`)](https://cli.github.com/) installed and authenticated.
* You have a Git repository open with a Pull Request you want to test this on.
## Creating a Pull Request Review Workflow
This workflow will automate the process of fetching PR details, analyzing the code changes for issues, and drafting a review comment.
<Steps>
<Step title="Create the Workflow File">
First, create the directory structure for your project-specific workflows.
1. In the root of your project, create a new folder named `.clinerules`.
2. Inside `.clinerules`, create another folder named `workflows`.
3. Finally, create a new file named `pr-review.md` inside the `workflows` folder.
</Step>
<Step title="Write the Workflow Content">
Open the `pr-review.md` file and add the following content. This workflow will gather PR details, analyze the changes, and help you submit a review.
````markdown pr-review.md
# Pull Request Reviewer
This workflow helps me review a pull request by analyzing the changes and drafting a review.
## 1. Gather PR Information
First, I need to understand what this PR is about. I'll fetch the title, description, and list of changed files.
```bash
gh pr view PR_NUMBER --json title,body,files
```
## 2. Examine Modified Files
Now I will examine the diff to understand the specific code changes.
```bash
gh pr diff PR_NUMBER
```
## 3. Analyze Changes
I will analyze the code changes for:
* **Bugs:** Logic errors or edge cases.
* **Performance:** Inefficient loops or operations.
* **Security:** Vulnerabilities or unsafe practices.
## 4. Confirm Assessment
Based on my analysis, I will present my findings and ask how you want to proceed.
```xml
<ask_followup_question>
<question>I've reviewed PR #PR_NUMBER. Here is my assessment:
[Insert Analysis Here]
Do you want me to approve this PR, request changes, or just leave a comment?</question>
<options>["Approve", "Request Changes", "Comment", "Do nothing"]</options>
</ask_followup_question>
```
## 5. Execute Review
Finally, I will execute the review command based on your decision.
```bash
# If approving:
gh pr review PR_NUMBER --approve --body "Looks good to me! [Summary of analysis]"
# If requesting changes:
gh pr review PR_NUMBER --request-changes --body "Please address the following: [Issues list]"
# If commenting:
gh pr review PR_NUMBER --comment --body "[Comments]"
```
````
<Note>
When you run this workflow, you will replace `PR_NUMBER` with the actual number of the pull request you want to review (e.g., `/pr-review.md 123`).
</Note>
</Step>
<Step title="Run the Workflow">
Now you're ready to run your new workflow.
1. Open the Cline chat panel.
2. Type `/pr-review.md` followed by the PR number (e.g., `/pr-review.md 42`) and press Enter.
3. Cline will fetch the PR details, analyze the code, and present you with its findings before submitting the review.
<Tip>
As Cline executes commands (like `gh pr view`), it may show you the output and pause. You will need to click the **Proceed While Running** button to allow Cline to analyze the content and continue with the workflow.
</Tip>
</Step>
</Steps>
### Other Common Use Cases
This is just one example. You can create workflows for a wide variety of tasks, such as:
* **Creating Components:** Automate the boilerplate for new files (like React components or API endpoints).
* **Running Tests:** Create a workflow that runs your test suite and summarizes the results.
* **Deploying Your Application:** Automate your deployment pipeline using tools like `docker` and `kubectl`.
* **Refactoring Code:** Guide Cline through a complex refactoring process step-by-step.
Explore Cline's capabilities and your own development processes to find repetitive tasks that can be turned into efficient workflows.
+34 -6
View File
@@ -5146,6 +5146,28 @@
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -6468,9 +6490,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -10213,6 +10235,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -10581,9 +10609,9 @@
}
},
"node_modules/tar-fs": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
"integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz",
"integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
-4
View File
@@ -14,9 +14,5 @@
"description": "",
"dependencies": {
"mintlify": "^4.2.23"
},
"overrides": {
"tar-fs": "^3.1.1",
"js-yaml": "^4.1.1"
}
}
+43 -14
View File
@@ -3,7 +3,7 @@ title: "Baseten"
description: "Learn how to configure and use Baseten's Model APIs with Cline. Access frontier open-source models with enterprise-grade performance, reliability, and competitive pricing."
---
Baseten provides on-demand frontier model APIs designed for production applications, not just experimentation. Built on the Baseten Inference Stack, these APIs deliver optimized inference for leading open-source models from OpenAI, DeepSeek, Moonshot AI, and Alibaba Cloud.
Baseten provides on-demand frontier model APIs designed for production applications, not just experimentation. Built on the Baseten Inference Stack, these APIs deliver enterprise-grade performance and reliability with optimized inference for leading open-source models from OpenAI, DeepSeek, Meta, Moonshot AI, and Alibaba Cloud.
**Website:** [https://www.baseten.co/products/model-apis/](https://www.baseten.co/products/model-apis/)
@@ -14,21 +14,13 @@ Baseten provides on-demand frontier model APIs designed for production applicati
3. **Create a Key:** Generate a new API key. Give it a descriptive name (e.g., "Cline").
4. **Copy the Key:** Copy the API key immediately and store it securely.
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
2. **Select Provider:** Choose "Baseten" from the "API Provider" dropdown.
3. **Enter API Key:** Paste your Baseten API key into the "Baseten API Key" field.
4. **Select Model:** Choose your desired model from the "Model" dropdown.
**IMPORTANT: For Kimi K2 Thinking:** To use the `moonshotai/Kimi-K2-Thinking` model, you must enable **Native Tool Call (Experimental)** in Cline settings. This setting allows Cline to call tools through their native tool processor and is required for this reasoning model to function properly.
### Supported Models
Cline supports all current models under Baseten Model APIs, including:
For the most updated pricing, please visit: https://www.baseten.co/products/model-apis/
Note: Kimi K2 0711, Llama 4 Maverick, and Llama 4 Scout Model APIs have been deprecated at 5pm PT on October 8th.
https://www.baseten.co/resources/changelog/model-api-deprecation-notice-kimi-k2-0711-scout-maverick/
- `moonshotai/Kimi-K2-Thinking` (Moonshot AI) - Enhanced reasoning capabilities with step-by-step thought processes (262K context) - \$0.60/\$2.50 per 1M tokens
- `zai-org/GLM-4.6` (Z AI) - Frontier open model with advanced agentic, reasoning and coding capabilities by Z AI (200k context) \$0.60/\$2.20 per 1M tokens
- `moonshotai/Kimi-K2-Instruct-0905` (Moonshot AI) - September update with enhanced capabilities (262K context) - \$0.60/\$2.50 per 1M tokens
- `openai/gpt-oss-120b` (OpenAI) - 120B MoE with strong reasoning capabilities (128K context) - \$0.10/\$0.50 per 1M tokens
@@ -39,6 +31,13 @@ For the most updated pricing, please visit: https://www.baseten.co/products/mode
- `deepseek-ai/DeepSeek-V3.1` - Hybrid reasoning with advanced tool calling (163K context) - \$0.50/\$1.50 per 1M tokens
- `deepseek-ai/DeepSeek-V3-0324` - Fast general-purpose with enhanced reasoning (163K context) - \$0.77/\$0.77 per 1M tokens
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
2. **Select Provider:** Choose "Baseten" from the "API Provider" dropdown.
3. **Enter API Key:** Paste your Baseten API key into the "Baseten API Key" field.
4. **Select Model:** Choose your desired model from the "Model" dropdown.
### Production-First Architecture
Baseten's Model APIs are built for production environments with several key advantages:
@@ -60,17 +59,47 @@ Baseten's Model APIs are built for production environments with several key adva
#### Developer Experience
- **OpenAI compatible API** - migrate by swapping a single URL
- **Drop-in replacement** for closed models with comprehensive observability and analytics
- **Drop-in replacement** for closed models with comprehensive observability
- **Seamless scaling** from Model APIs to dedicated deployments
### Special Features
#### Function Calling & Tool Use
All Baseten models support structured outputs, function calling, and tool use as part of the Baseten Inference Stack, making them ideal for agentic applications and coding workflows.
All Baseten models support structured outputs, function calling, and tool use as part of the Baseten Inference Stack, making them ideal for agentic applications.
#### Reasoning Capabilities
DeepSeek models offer enhanced reasoning with step-by-step thought processes, while maintaining production-ready performance.
#### Long Context Support
- **Up to 1 million tokens** for Llama 4 models (Maverick and Scout)
- **262K tokens** for Qwen3 models
- **163K tokens** for DeepSeek models
- **Perfect for code repositories** and complex multi-turn conversations
#### Quantization Optimizations
Models are deployed with advanced quantization techniques (fp4, fp8, fp16) for optimal performance while maintaining quality.
### Migration from Other Providers
Baseten's OpenAI compatibility makes migration straightforward:
**From OpenAI:**
- Swap `api.openai.com` with `inference.baseten.co/v1`
- Keep existing request/response formats
- Benefit from significant cost savings
**From Other Providers:**
- Use standard OpenAI SDK format
- Maintain existing prompting strategies
- Access to newer open-source models
### Tips and Notes
- **Dynamic Model Updates:** Cline automatically fetches the latest model list from Baseten, ensuring access to new models as they're released in real time.
- **Model Selection:** Choose models based on your specific use case - reasoning models for complex tasks, coding models for development work, and flagship models for general applications.
- **Cost Optimization:** Baseten offers some of the most competitive pricing in the market, especially for open-source models.
- **Context Windows:** Take advantage of large context windows (up to 1M tokens) for including substantial codebases and documentation.
- **Enterprise Ready:** Baseten is designed for production use with enterprise-grade security, compliance, and reliability.
- **Dynamic Model Updates:** Cline automatically fetches the latest model list from Baseten, ensuring access to new models as they're released.
- **Multi-Cloud Capacity Management (MCM):** Baseten's multi-cloud infrastructure ensures high availability and low latency globally.
- **Support:** Baseten provides dedicated support for production deployments and can work with you on dedicated resources as you scale.
+1422
View File
File diff suppressed because it is too large Load Diff
+45 -46
View File
@@ -1,48 +1,47 @@
{
"name": "cline-evals",
"version": "0.1.0",
"description": "Evaluation scripts and tools for Cline",
"main": "cli/dist/index.js",
"scripts": {
"build:cli": "cd cli && tsc",
"start:cli": "cd cli && node dist/index.js",
"dev:cli": "cd cli && ts-node src/index.ts",
"diff-eval": "./diff-edits/run_and_open_dashboard.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark",
"diff-edits"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"better-sqlite3": "^12.4.1",
"chalk": "5.6.2",
"dotenv": "^16.5.0",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"tiktoken": "^1.0.21",
"uuid": "^9.0.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},
"overrides": {
"tar-fs": "^3.1.1",
"js-yaml": "^4.1.1"
}
"name": "cline-evals",
"version": "0.1.0",
"description": "Evaluation scripts and tools for Cline",
"main": "cli/dist/index.js",
"scripts": {
"build:cli": "cd cli && tsc",
"start:cli": "cd cli && node dist/index.js",
"dev:cli": "cd cli && ts-node src/index.ts",
"diff-eval": "./diff-edits/run_and_open_dashboard.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark",
"diff-edits"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.12.0",
"better-sqlite3": "^12.4.1",
"chalk": "5.6.2",
"dotenv": "^16.5.0",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"tiktoken": "^1.0.21",
"uuid": "^9.0.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},
"overrides": {
"tar-fs": "^3.1.1"
}
}
+933 -407
View File
File diff suppressed because it is too large Load Diff
+15 -21
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.38.1",
"version": "3.37.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -46,15 +46,6 @@
],
"main": "./dist/extension.js",
"contributes": {
"icons": {
"cline-icon": {
"description": "cline",
"default": {
"fontPath": "assets/icons/cline-bot.woff",
"fontCharacter": "\\e900"
}
}
},
"walkthroughs": [
{
"id": "ClineWalkthrough",
@@ -183,7 +174,10 @@
"command": "cline.generateGitCommitMessage",
"title": "Generate Commit Message with Cline",
"category": "Cline",
"icon": "$(cline-icon)"
"icon": {
"light": "assets/icons/robot_panel_light.png",
"dark": "assets/icons/robot_panel_dark.png"
}
},
{
"command": "cline.abortGitCommitMessage",
@@ -312,6 +306,8 @@
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"build:npm": "scripts/build-npm-package.sh",
"build:docker:dev": "node scripts/build-docker-dev.mjs",
"docker:shell": "node scripts/docker-shell.mjs",
"test:install": "bash scripts/test-install.sh",
"dev:cli:watch": "node scripts/dev-cli-watch.mjs",
"postcompile-standalone": "node scripts/package-standalone.mjs",
@@ -365,8 +361,7 @@
"docs": "cd docs && npm run dev",
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook"
"report-issue": "node scripts/report-issue.js"
},
"lint-staged": {
"*": [
@@ -424,7 +419,7 @@
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
"@google/genai": "^1.30.0",
"@google/genai": "^1.11.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/reflection": "^1.0.4",
"@mistralai/mistralai": "^1.5.0",
@@ -449,8 +444,8 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-ai-sdk/ai-api": "^1.17.0",
"@sap-ai-sdk/orchestration": "^1.17.0",
"@sentry/browser": "^9.12.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
@@ -468,6 +463,7 @@
"exceljs": "^4.4.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
"firebase": "^11.2.0",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
@@ -477,6 +473,7 @@
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"jschardet": "^3.1.4",
"jwt-decode": "^4.0.0",
"mammoth": "^1.11.0",
"nanoid": "^5.1.6",
"nice-grpc": "^2.1.12",
@@ -484,7 +481,7 @@
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-mutex": "^1.0.0",
"p-timeout": "^6.1.4",
@@ -509,10 +506,7 @@
"zod": "^3.24.2"
},
"overrides": {
"tar-fs": ">=3.1.1",
"tar": "^7.5.2",
"vite": "^7.1.11",
"js-yaml": "^4.1.1"
"tar-fs": ">=3.1.1"
},
"c8": {
"reporter": [
-70
View File
@@ -69,18 +69,6 @@ service FileService {
// Opens or creates a focus chain checklist markdown file for editing
rpc openFocusChainFile(StringRequest) returns (Empty);
// Refreshes all hook toggles (discovers hooks and their enabled state)
rpc refreshHooks(EmptyRequest) returns (HooksToggles);
// Toggles a hook on or off via chmod +x/-x
rpc toggleHook(ToggleHookRequest) returns (ToggleHookResponse);
// Creates a new hook from template
rpc createHook(CreateHookRequest) returns (CreateHookResponse);
// Deletes an existing hook file
rpc deleteHook(DeleteHookRequest) returns (DeleteHookResponse);
}
// Response for refreshRules operation
@@ -220,61 +208,3 @@ message ToggleWorkflowRequest {
bool enabled = 3;
RuleScope scope = 4; // Scope of the workflow (local, global, or remote)
}
// Maps from hook name to enabled/disabled status
message HookInfo {
string name = 1;
bool enabled = 2;
string absolutePath = 3;
}
message WorkspaceHooks {
string workspace_name = 1;
repeated HookInfo hooks = 2;
}
message HooksToggles {
repeated HookInfo global_hooks = 1;
repeated WorkspaceHooks workspace_hooks = 2;
bool is_windows = 3; // Whether the system is Windows (toggles disabled)
}
// Request to toggle a hook
message ToggleHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook (e.g., "TaskStart")
bool is_global = 3; // Whether this is a global or workspace hook
bool enabled = 4; // Whether to enable (chmod +x) or disable (chmod -x)
optional string workspace_name = 5; // For multi-root workspaces, specifies which workspace
}
// Response for toggleHook operation
message ToggleHookResponse {
HooksToggles hooks_toggles = 1;
}
// Request to create a hook
message CreateHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook to create
bool is_global = 3; // Whether to create in global or workspace hooks directory
optional string workspace_name = 4; // For multi-root workspaces, specifies which workspace
}
// Response for createHook operation
message CreateHookResponse {
HooksToggles hooks_toggles = 1;
}
// Request to delete a hook
message DeleteHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook to delete
bool is_global = 3; // Whether this is a global or workspace hook
optional string workspace_name = 4; // For multi-root workspaces, specifies which workspace
}
// Response for deleteHook operation
message DeleteHookResponse {
HooksToggles hooks_toggles = 1;
}
-3
View File
@@ -97,7 +97,6 @@ message OpenRouterModelInfo {
optional bool supports_global_endpoint = 11;
repeated ModelTier tiers = 12;
optional string name = 13;
optional double temperature = 14;
}
// Shared response message for model information
@@ -589,7 +588,6 @@ message ModelsApiConfiguration {
optional string plan_mode_aihubmix_model_id = 135;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 136;
optional string plan_mode_nous_research_model_id = 137;
optional string gemini_plan_mode_thinking_level = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -630,5 +628,4 @@ message ModelsApiConfiguration {
optional string act_mode_aihubmix_model_id = 235;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 236;
optional string act_mode_nous_research_model_id = 237;
optional string gemini_act_mode_thinking_level = 238;
}
+1 -15
View File
@@ -362,7 +362,7 @@ message UpdateSettingsRequest {
optional int32 subagent_terminal_output_line_limit = 30;
optional string cline_env = 31;
optional bool native_tool_call_enabled = 32;
optional OnboardingModelGroup onboarding_models = 33;
optional bool show_onboarding_flow = 33;
}
message UpdateTerminalConnectionTimeoutRequest {
@@ -390,17 +390,3 @@ message OnboardingProgressRequest {
optional bool completed = 3;
optional string model_selected = 4;
}
message OnboardingModelGroup {
repeated OnboardingModel models = 1;
}
message OnboardingModel {
string id = 1;
string name = 2;
int32 score = 3;
int32 latency = 4;
string badge = 5;
string group = 6;
OpenRouterModelInfo info = 7;
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
import { execSync } from "child_process"
/**
* Build Docker image for Cline CLI
* This script builds a Docker image using pre-built binaries from dist-standalone/
*
* Prerequisites:
* - Run `npm run compile-standalone` first to build all platform binaries
* - Run `npm run compile-cli` first to build CLI binaries
*/
function runCommand(command, description) {
console.log(`\n${description}...`)
try {
execSync(command, { stdio: "inherit" })
console.log("✓ Success\n")
} catch (error) {
console.error(`✗ Failed: ${error.message}`)
process.exit(1)
}
}
function getCommandOutput(command) {
try {
return execSync(command, { encoding: "utf-8" }).trim()
} catch (error) {
return ""
}
}
function buildPrerequisites() {
console.log("Building prerequisites...\n")
// Build standalone (includes cline-core and platform-specific native modules)
runCommand("npm run compile-standalone", "Running npm run compile-standalone")
// Build CLI binaries for all platforms
runCommand("npm run compile-cli-all-platforms", "Running npm run compile-cli-all-platforms")
console.log("✓ All prerequisites built successfully\n")
}
function main() {
console.log("🐳 Building Cline CLI Docker Image\n")
// Remove existing container to ensure clean state after rebuild
const containerId = getCommandOutput(`docker ps -aq --filter "name=^cline-cli-dev$"`)
if (containerId) {
console.log("🗑️ Removing existing container to ensure fresh start...")
try {
execSync(`docker rm -f cline-cli-dev`, { stdio: "inherit" })
console.log("✓ Container removed\n")
} catch (error) {
console.log("Note: Container cleanup failed, continuing anyway\n")
}
}
buildPrerequisites()
// Build Docker image for native platform
// Docker will automatically use the correct architecture (arm64 on Apple Silicon, amd64 on Intel)
runCommand("docker build -f docker/Dockerfile -t cline-cli:dev .", "Building Docker image")
console.log("✅ Docker image built successfully!")
console.log("\n📋 Next steps:\n")
console.log("Interactive shell:")
console.log(" npm run docker:shell\n")
console.log("This will:")
console.log(" • Reuse existing 'cline-cli-dev' container if running")
console.log(" • Start stopped container if it exists")
console.log(" • Create new persistent container if none exists")
console.log(" • Mount current directory at /workspace")
console.log(" • Provide all CLI commands (cline auth, cline task, etc.)")
console.log("\nContainer persists between sessions. To remove:")
console.log(" docker rm -f cline-cli-dev\n")
}
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { execSync } from "child_process"
import { platform } from "os"
const CONTAINER_NAME = "cline-cli-dev"
function runCommand(command) {
try {
return execSync(command, { encoding: "utf-8" }).trim()
} catch (error) {
return ""
}
}
function getCurrentDirectory() {
// Get current working directory in a cross-platform way
return process.cwd()
}
function main() {
console.log("🐳 Cline CLI Docker Shell\n")
// Check if container exists (running or stopped)
const containerId = runCommand(`docker ps -a --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}"`)
if (containerId) {
// Check if container is running
const isRunning = runCommand(`docker ps --filter "id=${containerId}" --format "{{.ID}}"`)
if (isRunning) {
console.log(`📦 Connecting to running container: ${CONTAINER_NAME}\n`)
try {
execSync(`docker exec -it ${containerId} /bin/bash`, { stdio: "inherit" })
} catch (error) {
// User exited shell normally
}
} else {
console.log(`▶️ Starting stopped container: ${CONTAINER_NAME}\n`)
try {
execSync(`docker start ${containerId}`, { stdio: "inherit" })
execSync(`docker exec -it ${containerId} /bin/bash`, { stdio: "inherit" })
} catch (error) {
// User exited shell normally
}
}
} else {
console.log(`🚀 Creating new container: ${CONTAINER_NAME}\n`)
const cwd = getCurrentDirectory()
try {
// Use different volume mount syntax for Windows vs Unix
const isWindows = platform() === "win32"
const volumeMount = isWindows ? `${cwd.replace(/\\/g, "/")}:/workspace` : `${cwd}:/workspace`
execSync(
`docker run -it --name ${CONTAINER_NAME} -v "${volumeMount}" -w /workspace --entrypoint /bin/bash cline-cli:dev`,
{ stdio: "inherit" },
)
} catch (error) {
// User exited shell normally
}
}
}
main()
+1 -1
View File
@@ -11,7 +11,7 @@ import fs from "fs"
import https from "https"
import path from "path"
import { pipeline } from "stream/promises"
import * as tar from "tar"
import tar from "tar"
import { promisify } from "util"
import { createGunzip } from "zlib"
+1
View File
@@ -24,6 +24,7 @@ const TARGET_PLATFORMS = [
{ platform: "darwin", arch: "x64", targetDir: "darwin-x64" },
{ platform: "darwin", arch: "arm64", targetDir: "darwin-arm64" },
{ platform: "linux", arch: "x64", targetDir: "linux-x64" },
{ platform: "linux", arch: "arm64", targetDir: "linux-arm64" },
]
const SUPPORTED_BINARY_MODULES = ["better-sqlite3"]
+3 -3
View File
@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ModelInfo } from "@shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiHandler } from "../../core/api/index"
import { ApiStream } from "../../core/api/transform/stream"
@@ -33,7 +33,7 @@ export class DifyHandler implements ApiHandler {
}
}
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
console.log("[DIFY DEBUG] createMessage called with:", {
systemPromptLength: systemPrompt?.length || 0,
messagesCount: messages?.length || 0,
@@ -255,7 +255,7 @@ export class DifyHandler implements ApiHandler {
}
}
private convertMessagesToQuery(systemPrompt: string, messages: ClineStorageMessage[]): string {
private convertMessagesToQuery(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
// Dify's context is managed by `conversation_id`. The `query` should be the last user message.
// The system prompt is typically configured in the Dify App itself.
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
+29
View File
@@ -9,6 +9,14 @@ export interface EnvironmentConfig {
appBaseUrl: string
apiBaseUrl: string
mcpBaseUrl: string
firebase: {
apiKey: string
authDomain: string
projectId: string
storageBucket?: string
messagingSenderId?: string
appId?: string
}
}
class ClineEndpoint {
@@ -55,6 +63,14 @@ class ClineEndpoint {
appBaseUrl: "https://staging-app.cline.bot",
apiBaseUrl: "https://core-api.staging.int.cline.bot",
mcpBaseUrl: "https://core-api.staging.int.cline.bot/v1/mcp",
firebase: {
apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
authDomain: "cline-staging.firebaseapp.com",
projectId: "cline-staging",
storageBucket: "cline-staging.firebasestorage.app",
messagingSenderId: "853479478430",
appId: "1:853479478430:web:2de0dba1c63c3262d4578f",
},
}
case Environment.local:
return {
@@ -62,6 +78,11 @@ class ClineEndpoint {
appBaseUrl: "http://localhost:3000",
apiBaseUrl: "http://localhost:7777",
mcpBaseUrl: "https://api.cline.bot/v1/mcp",
firebase: {
apiKey: "AIzaSyD8wtkd1I-EICuAg6xgAQpRdwYTvwxZG2w",
authDomain: "cline-preview.firebaseapp.com",
projectId: "cline-preview",
},
}
default:
return {
@@ -69,6 +90,14 @@ class ClineEndpoint {
appBaseUrl: "https://app.cline.bot",
apiBaseUrl: "https://api.cline.bot",
mcpBaseUrl: "https://api.cline.bot/v1/mcp",
firebase: {
apiKey: "AIzaSyC5rx59Xt8UgwdU3PCfzUF7vCwmp9-K2vk",
authDomain: "cline-prod.firebaseapp.com",
projectId: "cline-prod",
storageBucket: "cline-prod.firebasestorage.app",
messagingSenderId: "941048379330",
appId: "1:941048379330:web:45058eedeefc5cdfcc485b",
},
}
}
}
File diff suppressed because it is too large Load Diff
-606
View File
@@ -1,606 +0,0 @@
import { ClineStorageMessage } from "@/shared/messages/content"
const APPLY_PATCH_PATCH_REGEX = /\*\*\* Begin Patch\s+([\s\S]*?)\s+\*\*\* End Patch/m
/**
* Convert apply_patch tool calls to write_to_file and replace_in_file format
*/
export function convertApplyPatchToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
// Map to track tool_use_id to converted tool info and original input
const toolUseIdMap = new Map<string, { name: string; input: any; originalInput: any }>()
return messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}
const convertedContent = message.content.map((block) => {
// Handle tool_use blocks
if (block.type === "tool_use" && block.name === "apply_patch") {
const converted = convertApplyPatchToToolCalls(block.input)
// Store the conversion with original input for matching tool_result
toolUseIdMap.set(block.id, { ...converted, originalInput: block.input })
return {
...block,
name: converted.name,
input: converted.input,
}
}
// Handle tool_result blocks
if (block.type === "tool_result") {
const conversion = toolUseIdMap.get(block.tool_use_id)
if (conversion) {
// Reconstruct the tool_result content to match apply_patch format
const reconstructedContent = reconstructApplyPatchResult(
block,
conversion.name,
conversion.input,
conversion.originalInput,
)
return {
...block,
content: reconstructedContent,
}
}
}
return block
})
return {
...message,
content: convertedContent,
}
})
}
interface ConvertedTool {
name: string
input: any
}
/**
* Parse apply_patch input and convert to write_to_file or replace_in_file format
*/
function convertApplyPatchToToolCalls(input: any): ConvertedTool {
const patchInput = typeof input === "string" ? input : input?.input || ""
// Parse the patch format
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
if (!patchMatch) {
// If we can't parse it, return as-is with write_to_file
return {
name: "write_to_file",
input: input,
}
}
const patchContent = patchMatch[1]
// Extract file operation (Add, Update, or Delete)
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
if (!fileMatch) {
return {
name: "write_to_file",
input: input,
}
}
const action = fileMatch[1]
const filePath = fileMatch[2].trim()
// If it's an Add operation, convert to write_to_file
if (action === "Add") {
// Extract the content after the file line
const contentAfterFile = patchContent.substring(fileMatch.index! + fileMatch[0].length)
return {
name: "write_to_file",
input: {
absolutePath: filePath,
content: extractNewContentFromPatch(contentAfterFile),
},
}
}
// If it's Update or Delete, convert to replace_in_file
if (action === "Update" || action === "Delete") {
const diff = convertPatchToDiff(patchContent.substring(fileMatch.index! + fileMatch[0].length))
return {
name: "replace_in_file",
input: {
absolutePath: filePath,
diff: diff,
},
}
}
// Fallback
return {
name: "write_to_file",
input: input,
}
}
/**
* Extract new content from add operation patch
*/
function extractNewContentFromPatch(patchContent: string): string {
// For Add operations, the patch should contain lines starting with +
const lines = patchContent.split("\n")
const contentLines: string[] = []
for (const line of lines) {
if (line.startsWith("+")) {
// Remove the + prefix and exactly ONE space if present (but not if it's a tab)
let content = line.substring(1)
if (content.startsWith(" ") && !content.startsWith("\t")) {
content = content.substring(1)
}
contentLines.push(content)
}
}
return contentLines.join("\n")
}
/**
* Convert V4A patch format to SEARCH/REPLACE format
*/
function convertPatchToDiff(patchContent: string): string {
const diffBlocks: string[] = []
const lines = patchContent.split("\n")
let i = 0
while (i < lines.length) {
const line = lines[i]
// Skip empty lines at the start
if (!line.trim() && i === 0) {
i++
continue
}
// Check if this is the start of a hunk (@@) or a direct change line
if (line.trim().startsWith("@@") || line.startsWith("-") || line.startsWith("+")) {
const currentSearch: string[] = []
const currentReplace: string[] = []
// Collect @@ context marker lines
// @@ prefix marks context lines. If @@something, then "something" is context.
// If just @@, then it's an empty context line.
while (i < lines.length && lines[i].trim().startsWith("@@")) {
const trimmedLine = lines[i].trim()
// Extract the actual context content after @@
const contextLine = trimmedLine.substring(2)
// Always add the context line (even if empty)
currentSearch.push(contextLine)
currentReplace.push(contextLine)
i++
}
if (i >= lines.length) {
break
}
// Collect all remaining lines in this hunk until we hit end of content or next @@
const hunkLines: string[] = []
while (i < lines.length) {
// Check if this is a new hunk (starts with @@)
if (lines[i].trim().startsWith("@@")) {
break
}
hunkLines.push(lines[i])
i++
}
// Now process the hunk to build SEARCH/REPLACE
let hasChanges = false
for (let j = 0; j < hunkLines.length; j++) {
const hunkLine = hunkLines[j]
if (hunkLine.startsWith("-")) {
hasChanges = true
// Strip the - prefix and exactly ONE space if present (but not if it's a tab)
let content = hunkLine.substring(1)
if (content.startsWith(" ") && !content.startsWith(" \t")) {
content = content.substring(1)
}
currentSearch.push(content)
} else if (hunkLine.startsWith("+")) {
hasChanges = true
// Strip the + prefix and exactly ONE space if present (but not if it's a tab)
let content = hunkLine.substring(1)
if (content.startsWith(" ") && !content.startsWith(" \t")) {
content = content.substring(1)
}
currentReplace.push(content)
} else {
// Context line without @@ prefix - add to both sides
currentSearch.push(hunkLine)
currentReplace.push(hunkLine)
}
}
// Create the diff block if we have changes
if (hasChanges && (currentSearch.length > 0 || currentReplace.length > 0)) {
diffBlocks.push(
"------- SEARCH\n" +
currentSearch.join("\n") +
"\n=======\n" +
currentReplace.join("\n") +
"\n+++++++ REPLACE",
)
}
} else {
i++
}
}
return diffBlocks.join("\n")
}
/**
* Reconstruct tool_result content to match apply_patch format by extracting
* the final file content and converting it back to V4A patch format
*/
function reconstructApplyPatchResult(
block: any,
convertedToolName: string,
_convertedInput: any,
originalInput: any,
): string | any[] {
// Extract the content from the tool_result
const content = typeof block.content === "string" ? block.content : ""
// Try to extract the final_file_content
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
if (!finalContentMatch) {
// If no final_file_content found, return original content
return block.content
}
const filePath = finalContentMatch[1]
const finalContent = finalContentMatch[2]
// Reconstruct the result message based on the converted tool type
if (convertedToolName === "write_to_file") {
// For write_to_file, we just need to confirm the file was created/written
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
}
if (convertedToolName === "replace_in_file") {
// For replace_in_file, we need to reconstruct the V4A patch format result
// Try to parse the original patch to get the action and build context
const patchInput = typeof originalInput === "string" ? originalInput : originalInput?.input || ""
const patchMatch = patchInput.match(APPLY_PATCH_PATCH_REGEX)
if (patchMatch) {
const patchContent = patchMatch[1]
const fileMatch = patchContent.match(/\*\*\* (Add|Update|Delete) File: (.+?)(?:\n|$)/m)
if (fileMatch) {
const action = fileMatch[1]
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using ${action} operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
}
// Fallback for replace_in_file
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
// Default fallback
return block.content
}
/**
* Convert write_to_file and replace_in_file tool calls to apply_patch format
*/
export function convertWriteToFileToolCalls(messages: Array<ClineStorageMessage>): Array<ClineStorageMessage> {
// Map to track tool_use_id to converted tool info and original input
const toolUseIdMap = new Map<string, { originalName: string; originalInput: any; patchInput?: string }>()
// First pass: collect tool_use blocks
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue
}
for (const block of message.content) {
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
toolUseIdMap.set(block.id, {
originalName: block.name,
originalInput: block.input,
})
}
}
}
// Second pass: find tool_results and extract final content to build proper patches
const finalContentMap = new Map<string, string>()
for (const message of messages) {
if (!Array.isArray(message.content)) {
continue
}
for (const block of message.content) {
if (block.type === "tool_result" && toolUseIdMap.has(block.tool_use_id)) {
const content = typeof block.content === "string" ? block.content : ""
const finalContentMatch = content.match(
/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/,
)
if (finalContentMatch) {
finalContentMap.set(block.tool_use_id, finalContentMatch[2])
}
}
}
}
// Third pass: convert messages
return messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}
const convertedContent = message.content.map((block) => {
// Handle tool_use blocks for write_to_file and replace_in_file
if (block.type === "tool_use" && (block.name === "write_to_file" || block.name === "replace_in_file")) {
const finalContent = finalContentMap.get(block.id)
const patchInput = convertToPatchFormat(block.name, block.input, finalContent)
// Update the map with the generated patch
const existingEntry = toolUseIdMap.get(block.id)
if (existingEntry) {
existingEntry.patchInput = patchInput
}
return {
...block,
name: "apply_patch",
input: {
input: patchInput,
},
}
}
// Handle tool_result blocks
if (block.type === "tool_result") {
const conversion = toolUseIdMap.get(block.tool_use_id)
if (conversion) {
// Reconstruct the tool_result content to match apply_patch format
const reconstructedContent = reconstructWriteToFileResult(
block,
conversion.originalName,
conversion.originalInput,
)
return {
...block,
content: reconstructedContent,
}
}
}
return block
})
return {
...message,
content: convertedContent,
}
})
}
/**
* Convert write_to_file or replace_in_file input to apply_patch format
*/
function convertToPatchFormat(toolName: string, input: any, finalContent?: string): string {
const filePath = input.absolutePath || input.path || ""
if (toolName === "write_to_file") {
// Convert write_to_file to Add operation
const content = input.content || ""
const lines = content.split("\n")
const patchLines = ["@@"]
patchLines.push(...lines.map((line: string) => `+ ${line}`))
return `apply_patch <<"EOF"
*** Begin Patch
*** Add File: ${filePath}
${patchLines.join("\n")}
*** End Patch
EOF`
}
if (toolName === "replace_in_file") {
// Convert replace_in_file to Update operation
const diff = input.diff || ""
// Parse SEARCH/REPLACE blocks and convert to V4A format with context
const patchContent = convertDiffToPatchWithContext(diff, finalContent)
return `apply_patch <<"EOF"
*** Begin Patch
*** Update File: ${filePath}
${patchContent}
*** End Patch
EOF`
}
return ""
}
/**
* Convert SEARCH/REPLACE diff format to V4A patch format with additional context from final content
*/
function convertDiffToPatchWithContext(diff: string, finalContent?: string): string {
const patchLines: string[] = []
// Match all SEARCH/REPLACE blocks
const blockRegex = /------- SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n\+{7} REPLACE/g
let match
while ((match = blockRegex.exec(diff)) !== null) {
const searchContent = match[1]
const replaceContent = match[2]
const searchLines = searchContent.split("\n")
const replaceLines = replaceContent.split("\n")
// Find common prefix and suffix between search and replace
let prefixEnd = 0
while (
prefixEnd < searchLines.length &&
prefixEnd < replaceLines.length &&
searchLines[prefixEnd] === replaceLines[prefixEnd]
) {
prefixEnd++
}
let suffixStart = searchLines.length
let replaceSuffixStart = replaceLines.length
while (
suffixStart > prefixEnd &&
replaceSuffixStart > prefixEnd &&
searchLines[suffixStart - 1] === replaceLines[replaceSuffixStart - 1]
) {
suffixStart--
replaceSuffixStart--
}
// If we have finalContent, extract additional context from it
if (finalContent) {
const finalLines = finalContent.split("\n")
// Find where the replaced content appears in the final file
let matchIndex = -1
for (let i = 0; i < finalLines.length; i++) {
// Try to match the first replace line
if (replaceLines.length > 0 && finalLines[i] === replaceLines[0]) {
// Check if subsequent lines also match
let allMatch = true
for (let j = 1; j < replaceLines.length && i + j < finalLines.length; j++) {
if (finalLines[i + j] !== replaceLines[j]) {
allMatch = false
break
}
}
if (allMatch) {
matchIndex = i
break
}
}
}
if (matchIndex >= 0) {
// Extract up to 3 lines before as context
const contextStart = Math.max(0, matchIndex - 3)
const contextLines: string[] = []
for (let i = contextStart; i < matchIndex; i++) {
contextLines.push(finalLines[i])
}
// Pad to 3 lines if needed (with empty strings)
while (contextLines.length < 3) {
contextLines.unshift("")
}
// Add @@ marker with the first context line
if (contextLines[0] === "") {
patchLines.push("@@")
} else {
patchLines.push(`@@${contextLines[0]}`)
}
// Add remaining context lines (without @@ marker)
for (let i = 1; i < contextLines.length; i++) {
patchLines.push(contextLines[i])
}
// Add common prefix lines (without +/- markers)
for (let i = 0; i < prefixEnd; i++) {
patchLines.push(searchLines[i])
}
// Add the actual changes (lines that differ)
for (let i = prefixEnd; i < suffixStart; i++) {
patchLines.push(`- ${searchLines[i]}`)
}
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
patchLines.push(`+ ${replaceLines[i]}`)
}
// Add common suffix lines (without +/- markers)
for (let i = suffixStart; i < searchLines.length; i++) {
patchLines.push(searchLines[i])
}
// Extract up to 3 lines after as trailing context (without @@ markers)
const contextEnd = Math.min(finalLines.length, matchIndex + replaceLines.length + 3)
for (let i = matchIndex + replaceLines.length; i < contextEnd; i++) {
patchLines.push(finalLines[i])
}
continue
}
}
// Fallback: if no finalContent or couldn't find match, use the prefix/suffix from SEARCH/REPLACE
patchLines.push("@@")
// Add common prefix lines (without +/- markers)
for (let i = 0; i < prefixEnd; i++) {
patchLines.push(searchLines[i])
}
// Add the actual changes (lines that differ)
for (let i = prefixEnd; i < suffixStart; i++) {
patchLines.push(`- ${searchLines[i]}`)
}
for (let i = prefixEnd; i < replaceSuffixStart; i++) {
patchLines.push(`+ ${replaceLines[i]}`)
}
// Add common suffix lines (without +/- markers)
for (let i = suffixStart; i < searchLines.length; i++) {
patchLines.push(searchLines[i])
}
}
return patchLines.join("\n")
}
/**
* Reconstruct tool_result content to match apply_patch result format
*/
function reconstructWriteToFileResult(block: any, originalToolName: string, originalInput: any): string | any[] {
// Extract the content from the tool_result
const content = typeof block.content === "string" ? block.content : ""
// Try to extract the final_file_content
const finalContentMatch = content.match(/<final_file_content path="([^"]+)">\s*([\s\S]*?)\s*<\/final_file_content>/)
const filePath = originalInput.absolutePath || originalInput.path || ""
if (!finalContentMatch) {
// If no final_file_content found, create a simple success message
if (originalToolName === "write_to_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
} else {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified.`
}
}
const finalContent = finalContentMatch[2]
// Reconstruct the result message based on the original tool type
if (originalToolName === "write_to_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully saved to ${filePath}.\n\nThe file has been created/updated with the new content.`
}
if (originalToolName === "replace_in_file") {
return `[apply_patch for '${filePath}'] Result:\nThe content was successfully updated in ${filePath}.\n\nThe file has been modified using Update operation.\n\n<final_file_content path="${filePath}">\n${finalContent}\n</final_file_content>\n\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.`
}
// Default fallback
return block.content
}
-60
View File
@@ -1,60 +0,0 @@
import { ClineStorageMessage } from "@/shared/messages/content"
import { ClineDefaultTool } from "@/shared/tools"
import { convertApplyPatchToolCalls, convertWriteToFileToolCalls } from "./diff-editors"
/**
* Transforms tool call messages between different tool formats based on native tool support.
* Converts between apply_patch and write_to_file/replace_in_file formats as needed.
*
* @param clineMessages - Array of messages containing tool calls to transform
* @param nativeTools - Array of tools natively supported by the current provider
* @returns Transformed messages array, or original if no transformation needed
*/
export function transformToolCallMessages(
clineMessages: ClineStorageMessage[],
nativeTools?: ClineDefaultTool[],
): ClineStorageMessage[] {
// Early return if no messages or native tools provided
if (!clineMessages?.length || !nativeTools?.length) {
return clineMessages
}
// Create Sets for O(1) lookup performance
const nativeToolSet = new Set(nativeTools)
const usedToolSet = new Set<string>()
// Single pass: collect all tools used in assistant messages
for (const msg of clineMessages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name) {
usedToolSet.add(block.name)
}
}
}
}
// Early return if no tools were used
if (usedToolSet.size === 0) {
return clineMessages
}
// Determine which conversion to apply
const hasApplyPatchNative = nativeToolSet.has(ClineDefaultTool.APPLY_PATCH)
const hasFileEditNative = nativeToolSet.has(ClineDefaultTool.FILE_EDIT) || nativeToolSet.has(ClineDefaultTool.FILE_NEW)
const hasApplyPatchUsed = usedToolSet.has(ClineDefaultTool.APPLY_PATCH)
const hasFileEditUsed = usedToolSet.has(ClineDefaultTool.FILE_EDIT) || usedToolSet.has(ClineDefaultTool.FILE_NEW)
// Convert write_to_file/replace_in_file → apply_patch
if (hasApplyPatchNative && hasFileEditUsed) {
return convertWriteToFileToolCalls(clineMessages)
}
// Convert apply_patch → write_to_file/replace_in_file
if (hasFileEditNative && hasApplyPatchUsed) {
return convertApplyPatchToolCalls(clineMessages)
}
return clineMessages
}
+3 -5
View File
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { ClineStorageMessage } from "@/shared/messages/content"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
import { AnthropicHandler } from "./providers/anthropic"
@@ -47,8 +47,9 @@ import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
export type CommonApiHandlerOptions = {
onRetryAttempt?: ApiConfiguration["onRetryAttempt"]
}
export interface ApiHandler {
createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ClineTool[], useResponseApi?: boolean): ApiStream
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream
getModel(): ApiHandlerModel
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
}
@@ -94,7 +95,6 @@ function createHandlerForProvider(
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiThinkingLevel: mode === "plan" ? options.geminiPlanModeThinkingLevel : options.geminiActModeThinkingLevel,
})
case "bedrock":
return new AwsBedrockHandler({
@@ -167,7 +167,6 @@ function createHandlerForProvider(
geminiBaseUrl: options.geminiBaseUrl,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
thinkingLevel: mode === "plan" ? options.geminiPlanModeThinkingLevel : options.geminiActModeThinkingLevel,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
ulid: options.ulid,
})
@@ -252,7 +251,6 @@ function createHandlerForProvider(
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
geminiThinkingLevel: mode === "plan" ? options.geminiPlanModeThinkingLevel : options.geminiActModeThinkingLevel,
})
case "litellm":
return new LiteLlmHandler({
@@ -1,8 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import "should"
import { ClaudeCodeHandler } from "@core/api/providers/claude-code"
import { ClineStorageMessage } from "@/shared/messages/content"
describe("ClaudeCodeHandler", () => {
let handler: ClaudeCodeHandler
@@ -71,7 +71,7 @@ describe("ClaudeCodeHandler", () => {
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
@@ -140,7 +140,7 @@ describe("ClaudeCodeHandler", () => {
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
@@ -199,7 +199,7 @@ describe("ClaudeCodeHandler", () => {
runClaudeCodeStub.returns(mockGenerator() as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const usageData: any[] = []
@@ -1,8 +1,8 @@
import Anthropic from "@anthropic-ai/sdk"
import { LiteLlmHandler, type LiteLlmModelInfoResponse } from "@core/api/providers/litellm"
import { convertToOpenAiMessages } from "@core/api/transform/openai-format"
import { expect } from "chai"
import sinon from "sinon"
import { ClineStorageMessage } from "@/shared/messages/content"
import { mockFetchForTesting } from "@/shared/net"
const fakeClient = {
@@ -109,7 +109,7 @@ describe("LiteLlmHandler", () => {
it("sends the system prompt and messages with the openai format", async () => {
const systemPrompt = "Test System Prompt"
const messages: ClineStorageMessage[] = [
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "first message",
@@ -161,7 +161,7 @@ describe("LiteLlmHandler", () => {
it("inserts the cache control in the system prompt and the last two user messages", async () => {
const systemPrompt = "Test System Prompt"
const messages: ClineStorageMessage[] = [
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "first message",
@@ -1,9 +1,9 @@
import { afterEach, before, beforeEach, describe, it } from "mocha"
import "should"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandlerOptions } from "@shared/api"
import axios from "axios"
import sinon from "sinon"
import { ClineStorageMessage } from "@/shared/messages/content"
import { OllamaHandler } from "../ollama"
describe("OllamaHandler", () => {
@@ -59,7 +59,7 @@ describe("OllamaHandler", () => {
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
const usageInfo = []
@@ -114,7 +114,7 @@ describe("OllamaHandler", () => {
}
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
// Start the request and catch the error
let errorMessage = ""
@@ -158,7 +158,7 @@ describe("OllamaHandler", () => {
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
@@ -204,7 +204,7 @@ describe("OllamaHandler", () => {
}
const systemPrompt = "You are a helpful assistant."
const messages: ClineStorageMessage[] = [{ role: "user", content: "Hello" }]
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
+20 -8
View File
@@ -2,8 +2,8 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { AnthropicModelId, anthropicDefaultModelId, anthropicModels, CLAUDE_SONNET_1M_SUFFIX, ModelInfo } from "@shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
@@ -43,7 +43,7 @@ export class AnthropicHandler implements ApiHandler {
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: AnthropicTool[]): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
@@ -74,7 +74,19 @@ export class AnthropicHandler implements ApiHandler {
case "claude-opus-4-1-20250805":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307": {
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
/*
The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
*/
const userMsgIndices = messages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index)
}
return acc
}, [] as number[])
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
const anthropicMessages = sanitizeAnthropicMessages(messages, lastUserMsgIndex, secondLastMsgUserIndex)
stream = await client.messages.create(
{
@@ -94,7 +106,7 @@ export class AnthropicHandler implements ApiHandler {
messages: anthropicMessages,
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
stream: true,
tools: nativeToolsOn ? tools : undefined,
tools: nativeToolsOn ? (tools as AnthropicTool[]) : undefined,
// tool_choice options:
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
@@ -123,9 +135,9 @@ export class AnthropicHandler implements ApiHandler {
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [{ text: systemPrompt, type: "text" }],
messages: sanitizeAnthropicMessages(messages, false),
tools: nativeToolsOn ? tools : undefined,
tool_choice: { type: "auto" },
messages: sanitizeAnthropicMessages(messages),
// tools,
// tool_choice: { type: "auto" },
stream: true,
})
break
@@ -204,7 +216,7 @@ export class AnthropicHandler implements ApiHandler {
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
// 'reasoning' type just displays in the UI, but reasoning with signature will be used to send the thinking traces back to the API
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,

Some files were not shown because too many files have changed in this diff Show More