mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a07554f191 | |||
| 474c655240 | |||
| b5157a2376 | |||
| b14db72140 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix: do not retry request automatically on auth failure.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add support for sonar and sonar-pro models to SAP AI Core Provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix task timeline display height.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Ensure tool arguments are streamed during file operations when native tool calling is enabled.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added thinking level setting for Gemini 3.0 Pro
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Feat: add thought signature support for Gemini SDK
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Feat: Enable native tool calling for Baseten and Kimi K2 models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Replaces generic robot icon with Cline logo across VS Code UI
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add Kimi K2 Thinking to Baseten Provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Skip MCP tool with invalid name (e.g. name too long) when native tool calling is enabled.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix Anthropic provider missing signature param when thinking is enabled.
|
||||
@@ -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
|
||||
|
||||
Vendored
-21
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
-20
@@ -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
@@ -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
|
||||
|
||||
@@ -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=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" 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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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*
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
Generated
+34
-6
@@ -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",
|
||||
|
||||
@@ -14,9 +14,5 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"mintlify": "^4.2.23"
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": "^3.1.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Generated
+1422
File diff suppressed because it is too large
Load Diff
+45
-46
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+933
-407
File diff suppressed because it is too large
Load Diff
+15
-21
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user