mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
223 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1553611dbc | |||
| 64bd618779 | |||
| fc32061c35 | |||
| aeab0d25be | |||
| d19c043b14 | |||
| b5fde0adbf | |||
| 7135fe4c49 | |||
| 1241a2fce2 | |||
| 0957e86046 | |||
| c7712e69d2 | |||
| e56490106c | |||
| 2fce6ef194 | |||
| b50c2db8b8 | |||
| d0da0b22db | |||
| cc0c9560be | |||
| 844ecdee38 | |||
| 128b721c0e | |||
| b5ae93dec9 | |||
| 24b5bfd77e | |||
| e7dc04fe56 | |||
| 42bad19889 | |||
| 5c7bb7c5db | |||
| 8c05a9923d | |||
| 67ed86ada7 | |||
| f212ba20d6 | |||
| 77e3e1e4ed | |||
| 0858ff517a | |||
| 3472e6068c | |||
| a670c1efa2 | |||
| 078f33a285 | |||
| a09f70023e | |||
| 4247f7f0d5 | |||
| 787cd3063d | |||
| 33b3dde9e8 | |||
| 518733d875 | |||
| 3b636e1a76 | |||
| 79fd3a99d9 | |||
| b7a5e3290a | |||
| b1ae417e05 | |||
| 2d496ed62c | |||
| 1704df14e1 | |||
| e65590c9a0 | |||
| a43d375366 | |||
| 7f05e06d8e | |||
| 724c7c778c | |||
| 522d00411d | |||
| a92f9d459d | |||
| 2d58c5e9f2 | |||
| 835204c7eb | |||
| bb4211ff2a | |||
| 261fc7f3d8 | |||
| 554e4d1b94 | |||
| 6476f723d9 | |||
| beb7ada9b7 | |||
| 2292cafbb3 | |||
| c9550bf357 | |||
| 0c63eaac20 | |||
| 8d2ee1dffb | |||
| 0ea263c1b1 | |||
| 8be46f9cfe | |||
| 4469a4fea4 | |||
| feee75b158 | |||
| 09933e7ad5 | |||
| 5b406eca72 | |||
| e80e0ebc40 | |||
| d14d05547d | |||
| c3e24e5d6b | |||
| 5572096947 | |||
| a85acf8898 | |||
| 1eaeb1812d | |||
| 9f25f8ae42 | |||
| 816c7ede4a | |||
| 29139a6e13 | |||
| c1195746d1 | |||
| a33a3d9186 | |||
| e25fbc1397 | |||
| 2917cd234c | |||
| 63a3896669 | |||
| 529bb3a26c | |||
| 541b51c0fb | |||
| 77395a3088 | |||
| 124fc6f147 | |||
| 097f8e6239 | |||
| a037ba8965 | |||
| f9f96b0e8a | |||
| a5699e883d | |||
| 1cee9f33a7 | |||
| 1b1e66f9d1 | |||
| 888968d387 | |||
| 3fa41068ba | |||
| 4c820db231 | |||
| afa3eed7eb | |||
| 9b6a120da4 | |||
| 80f84b551e | |||
| 8456f45a80 | |||
| e288bb9e4f | |||
| a369551a9e | |||
| b0fa1be2df | |||
| 793db91291 | |||
| 9cb0e51ffc | |||
| d68bf862a8 | |||
| c2f69737aa | |||
| 99b8b92b27 | |||
| 1239fe1b33 | |||
| 062e597b74 | |||
| d099be10fd | |||
| 3e8268605f | |||
| 1615bf3f82 | |||
| 15f713d26a | |||
| 5babd9e061 | |||
| ba12672600 | |||
| 163bf77677 | |||
| edf5ea00f6 | |||
| c3c80ffc4d | |||
| f14ed8506d | |||
| 41202df74e | |||
| ab88599e05 | |||
| c2dc0e531c | |||
| 246b0fa999 | |||
| 6c099fbe12 | |||
| 00bf05c26c | |||
| da99e2bf4b | |||
| 963e2c00eb | |||
| 9355d61bc1 | |||
| a9e17fee57 | |||
| 3e8548a341 | |||
| 76b86ff0c0 | |||
| 684438b44c | |||
| 3c84388fb2 | |||
| b0f86201d2 | |||
| d1fc59758e | |||
| 87c9f58902 | |||
| cda3eb8236 | |||
| 3c21d2be1f | |||
| f3adf68775 | |||
| 6bd3181133 | |||
| dd3a234a69 | |||
| c496f8a90d | |||
| 25b1cf91fc | |||
| b54e2043fe | |||
| 98e5ccc547 | |||
| 813a9589d0 | |||
| 64eb66d49a | |||
| 8c17d864c8 | |||
| 4f931c2d9d | |||
| 5836db3093 | |||
| 18879edf9f | |||
| 1cc702c8b9 | |||
| c8caa6f9b9 | |||
| 01f61b6765 | |||
| b8dd6abe61 | |||
| fb7f0e36fe | |||
| 403a32b69c | |||
| ee6daed7bb | |||
| 55ffe9e5dd | |||
| c2ec5fd17d | |||
| 5a84cb1145 | |||
| aec2fd5a7b | |||
| 5826cb584e | |||
| 76232639c3 | |||
| 3173483c0f | |||
| 8caf37600b | |||
| 58b14c69b1 | |||
| b831100f20 | |||
| 23fa305eac | |||
| 0762e6406d | |||
| 94da9f6669 | |||
| fdeef9cece | |||
| dd055b3327 | |||
| e852c6953b | |||
| aef27eb1c4 | |||
| 863572031f | |||
| 65eee1ac6a | |||
| 8213b0b910 | |||
| f5fc3fed6f | |||
| 42df03177f | |||
| 648ae1b1fd | |||
| d3cff6ac47 | |||
| 13af435103 | |||
| 3b1477bc24 | |||
| 688f93db6d | |||
| a702270e85 | |||
| 03acada1b9 | |||
| 95cca05f5e | |||
| 33d77eb095 | |||
| 3e0c39acbb | |||
| 366d8a5411 | |||
| d22596e5c2 | |||
| 7cc612dbce | |||
| f0cab63a43 | |||
| 3c1b670b73 | |||
| 5de05d68ef | |||
| 9b6b1c376c | |||
| e5e26f45fa | |||
| b794583b7a | |||
| ca96bd8f62 | |||
| 0ecdf8d0cc | |||
| 5be6ba68a3 | |||
| 1bdd1e943f | |||
| c85a4abeb4 | |||
| 011b19225e | |||
| a3945dce7f | |||
| 8daca03996 | |||
| 777b8576f2 | |||
| 880755ec89 | |||
| 1c70089521 | |||
| fe2a8a9477 | |||
| eb2550ec3d | |||
| 9c3ac14cab | |||
| 6a8f900d75 | |||
| 407e472322 | |||
| 580b2e35e2 | |||
| 30c121509f | |||
| b3aee3857c | |||
| 10cbbb2c6b | |||
| 08365b3e0b | |||
| cb4c61b1ca | |||
| 2d9ff863b7 | |||
| ae9b20a12b | |||
| c16e271c14 | |||
| b940cef0e4 | |||
| bc228de20e | |||
| a9cac3206a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added getCwdHash proto
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add version information injection to CLI build script. The CLI binaries now include version, commit hash, build date, and builder information extracted from package.json and git, improving debugging and version tracking capabilities.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Baseten Provider Model APIs Deprecation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Checkpoints multiroot pt.1: Accept array of workspaces when initializting checkpoints
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added updateApiConfigurationPartial with FieldMask to allow for partial ApiProvider updates
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Run Testing platform within Test workflow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add auto-retry with exponential backof for failed API requests
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
remove temperature settings in z.ai models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added subscribeToCheckpoints proto
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
auto-cleanup stale default instance config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add interactive provider configuration wizard with add/list capabilities, support for 8 API providers (Anthropic, OpenAI, OpenAI Native, OpenRouter, X AI, AWS Bedrock, Google Gemini, Ollama), and UpdateSettings gRPC implementation for persisting configurations to Cline Core state.
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse Hook Example
|
||||
#
|
||||
# This hook runs AFTER a tool is executed. It can:
|
||||
# 1. Observe tool results and outcomes
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Log or track tool usage patterns
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool has already completed when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
|
||||
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
|
||||
success=$(echo "$input" | jq -r '.postToolUse.success // false')
|
||||
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
|
||||
|
||||
# Example 1: Learning from file operations
|
||||
# Track successful file creations to build context about project structure
|
||||
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 2: Performance monitoring
|
||||
# Warn about slow operations
|
||||
# if [[ "$execution_time" -gt 5000 ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 3: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
|
||||
}
|
||||
EOF
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM PostToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs AFTER a tool is executed. It can:
|
||||
REM 1. Observe tool results and outcomes
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Log or track tool usage patterns
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
REM The tool has already completed when this hook runs.
|
||||
|
||||
REM Simple example: Always allow continuation
|
||||
echo {"shouldContinue": true}
|
||||
|
||||
REM To add context based on results, use:
|
||||
REM echo {"shouldContinue": true, "contextModification": "TOOL_RESULT: Operation completed successfully"}
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook - Advanced Example with Input Parsing
|
||||
REM This version reads and parses the JSON input from stdin using PowerShell
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read all input from stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse JSON and make decisions using PowerShell
|
||||
REM Note: We use -replace to handle special characters in the input
|
||||
powershell -NoProfile -Command ^
|
||||
"$input = '%INPUT%' -replace \"'\", \"''\"; ^
|
||||
try { ^
|
||||
$json = $input | ConvertFrom-Json; ^
|
||||
$toolName = $json.preToolUse.toolName; ^
|
||||
$shouldBlock = $false; ^
|
||||
$errorMsg = ''; ^
|
||||
$context = ''; ^
|
||||
if ($toolName -eq 'write_to_file') { ^
|
||||
$path = $json.preToolUse.parameters.path; ^
|
||||
if ($path -match '\\.js$') { ^
|
||||
$shouldBlock = $true; ^
|
||||
$errorMsg = 'Cannot create .js files in TypeScript project'; ^
|
||||
$context = 'WORKSPACE_RULES: Use .ts/.tsx extensions only'; ^
|
||||
} ^
|
||||
} ^
|
||||
$output = @{ ^
|
||||
shouldContinue = -not $shouldBlock; ^
|
||||
}; ^
|
||||
if ($errorMsg) { $output.errorMessage = $errorMsg }; ^
|
||||
if ($context) { $output.contextModification = $context }; ^
|
||||
$output | ConvertTo-Json -Compress; ^
|
||||
} catch { ^
|
||||
@{ shouldContinue = $true } | ConvertTo-Json -Compress; ^
|
||||
}"
|
||||
|
||||
endlocal
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse Hook Example
|
||||
#
|
||||
# This hook runs BEFORE a tool is executed. It can:
|
||||
# 1. Block execution by returning {"shouldContinue": false}
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Validate tool parameters
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool parameters are already determined when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
|
||||
|
||||
# Example 1: Validation - Block invalid operations
|
||||
# Uncomment to prevent creating .js files in a TypeScript project
|
||||
# if [[ "$tool_name" == "write_to_file" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# if [[ "$path" == *.js ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": false,
|
||||
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
|
||||
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
# fi
|
||||
|
||||
# Example 2: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request after this tool completes
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
|
||||
}
|
||||
EOF
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs BEFORE a tool is executed. It can:
|
||||
REM 1. Block execution by returning {"shouldContinue": false}
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Validate tool parameters
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
|
||||
REM Simple example: Always allow execution with workspace context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: This is a TypeScript project. Use .ts/.tsx extensions for new files."}
|
||||
|
||||
REM To block execution, use:
|
||||
REM echo {"shouldContinue": false, "errorMessage": "Operation not allowed"}
|
||||
@@ -0,0 +1,290 @@
|
||||
# Cline Hooks Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks are placed in the `.clinerules/hooks/` directory and run automatically when enabled.
|
||||
|
||||
## Enabling Hooks
|
||||
|
||||
1. Open Cline settings in VSCode
|
||||
2. Navigate to the Feature Settings section
|
||||
3. Check the "Enable Hooks" checkbox
|
||||
4. Hooks must be executable files (on Unix/Linux/macOS use `chmod +x hookname`)
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### PreToolUse Hook
|
||||
- **When**: Runs BEFORE a tool is executed
|
||||
- **Purpose**: Validate parameters, block execution, or add context
|
||||
- **File**: `.clinerules/hooks/PreToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PreToolUse.bat/.cmd/.exe` (Windows)
|
||||
|
||||
### PostToolUse Hook
|
||||
- **When**: Runs AFTER a tool completes
|
||||
- **Purpose**: Observe results, track patterns, or add context
|
||||
- **File**: `.clinerules/hooks/PostToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PostToolUse.bat/.cmd/.exe` (Windows)
|
||||
|
||||
## Platform-Specific Guidance
|
||||
|
||||
### Windows Hooks
|
||||
|
||||
Windows hooks use different file extensions and syntax than Unix hooks. Cline automatically searches for hooks using your system's `PATHEXT` environment variable (typically `.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.MSC`).
|
||||
|
||||
**Recommended approach for Windows:**
|
||||
- Use `.cmd` or `.bat` batch files (most compatible)
|
||||
- See `PreToolUse.example.cmd` and `PostToolUse.example.cmd` for simple examples
|
||||
- See `PreToolUse.advanced.example.cmd` for PowerShell-based JSON parsing
|
||||
|
||||
**Simple Windows Hook Example:**
|
||||
```batch
|
||||
@echo off
|
||||
REM Always allow execution with context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: TypeScript project"}
|
||||
```
|
||||
|
||||
**Advanced Windows Hook with Input Parsing:**
|
||||
```batch
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse and process JSON
|
||||
powershell -Command ^
|
||||
"$json = '%INPUT%' | ConvertFrom-Json; ^
|
||||
$output = @{shouldContinue = $true}; ^
|
||||
$output | ConvertTo-Json -Compress"
|
||||
```
|
||||
|
||||
**Tips for Windows:**
|
||||
- Batch files don't require `chmod +x` - they're executable by default
|
||||
- Use `REM` for comments instead of `#`
|
||||
- PowerShell is available on all modern Windows systems
|
||||
- For complex logic, consider PowerShell scripts (`.ps1`) or compiled executables (`.exe`)
|
||||
|
||||
### Unix/Linux/macOS Hooks
|
||||
|
||||
Unix hooks are shell scripts without file extensions:
|
||||
- Must be executable: `chmod +x PreToolUse`
|
||||
- Must include shebang: `#!/usr/bin/env bash` or `#!/usr/bin/env node`
|
||||
- See `PreToolUse.example` and `PostToolUse.example` for bash examples
|
||||
|
||||
## Context Injection Timing
|
||||
|
||||
**IMPORTANT**: Context injected by hooks affects **FUTURE AI decisions**, not the current tool execution.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
When a hook runs:
|
||||
1. The AI has already decided what tool to use and with what parameters
|
||||
2. The hook cannot modify those parameters
|
||||
3. Context from the hook is added to the conversation
|
||||
4. The AI sees this context in the **NEXT API request** and can adjust future decisions
|
||||
|
||||
### PreToolUse Hook Flow
|
||||
```
|
||||
1. AI decides: "I'll use write_to_file with these parameters"
|
||||
2. PreToolUse hook runs → can block or add context
|
||||
3. If allowed, tool executes with original parameters
|
||||
4. Context is added to conversation
|
||||
5. Next API request includes this context
|
||||
6. AI adjusts future decisions based on context
|
||||
```
|
||||
|
||||
### PostToolUse Hook Flow
|
||||
```
|
||||
1. Tool completes execution
|
||||
2. PostToolUse hook runs → observes results
|
||||
3. Hook adds context about the outcome
|
||||
4. Context is added to conversation
|
||||
5. Next API request includes this context
|
||||
6. AI can learn from the results
|
||||
```
|
||||
|
||||
## Hook Input/Output
|
||||
|
||||
### Input (via stdin as JSON)
|
||||
|
||||
All hooks receive:
|
||||
```json
|
||||
{
|
||||
"clineVersion": "string",
|
||||
"hookName": "PreToolUse" | "PostToolUse",
|
||||
"timestamp": "string",
|
||||
"taskId": "string",
|
||||
"workspaceRoots": ["string"],
|
||||
"userId": "string",
|
||||
"preToolUse": { // Only for PreToolUse
|
||||
"toolName": "string",
|
||||
"parameters": {}
|
||||
},
|
||||
"postToolUse": { // Only for PostToolUse
|
||||
"toolName": "string",
|
||||
"parameters": {},
|
||||
"result": "string",
|
||||
"success": boolean,
|
||||
"executionTimeMs": number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Output (via stdout as JSON)
|
||||
|
||||
All hooks must return:
|
||||
```json
|
||||
{
|
||||
"shouldContinue": boolean, // Required: Allow or block execution
|
||||
"contextModification": "string", // Optional: Context for future tool uses
|
||||
"errorMessage": "string" // Optional: Error details if blocking
|
||||
}
|
||||
```
|
||||
|
||||
## Context Modification Format
|
||||
|
||||
Use structured prefixes to help the AI understand context type:
|
||||
|
||||
- `WORKSPACE_RULES:` - Project conventions and requirements
|
||||
- `FILE_OPERATIONS:` - File creation/modification patterns
|
||||
- `TOOL_RESULT:` - Outcomes of tool executions
|
||||
- `PERFORMANCE:` - Performance concerns
|
||||
- `VALIDATION:` - Validation results
|
||||
- Custom prefixes as needed
|
||||
|
||||
Example:
|
||||
```bash
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## Hook Execution Limits
|
||||
|
||||
- **Timeout**: Hooks must complete within 30 seconds
|
||||
- **Context Size**: Context modifications are limited to 50KB
|
||||
- **Error Handling**: Unexpected file system errors are propagated; expected errors (file not found, permission denied) are handled silently
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Validation - Block Invalid Operations
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
|
||||
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
|
||||
|
||||
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": false,
|
||||
"errorMessage": "Cannot create .js files in TypeScript project",
|
||||
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
### 2. Context Building - Learn from Operations
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
|
||||
success=$(echo "$input" | jq -r '.postToolUse.success')
|
||||
path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
|
||||
|
||||
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
fi
|
||||
```
|
||||
|
||||
### 3. Performance Monitoring
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs')
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
|
||||
|
||||
if [[ "$execution_time" -gt 5000 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. Logging and Telemetry
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
|
||||
# Log to file
|
||||
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
|
||||
|
||||
# Allow execution
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
## Multi-Root Workspaces
|
||||
|
||||
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks will run and their results will be combined:
|
||||
|
||||
- **shouldContinue**: If ANY hook returns false, execution is blocked
|
||||
- **contextModification**: All context modifications are concatenated
|
||||
- **errorMessage**: All error messages are concatenated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Running
|
||||
- Ensure the "Enable Hooks" setting is checked
|
||||
- Verify the hook file is executable (`chmod +x hookname`)
|
||||
- Check the hook file has no syntax errors
|
||||
- Look for errors in VSCode's Output panel (Cline channel)
|
||||
|
||||
### Hook Timing Out
|
||||
- Reduce complexity of the hook script
|
||||
- Avoid expensive operations (network calls, heavy computations)
|
||||
- Consider moving complex logic to a background process
|
||||
|
||||
### Context Not Affecting Behavior
|
||||
- Remember: context affects FUTURE decisions, not the current tool
|
||||
- Use PreToolUse for validation (blocking) if you need immediate effect
|
||||
- Ensure context modifications are clear and actionable
|
||||
- Check that context isn't being truncated (50KB limit)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Hooks run with the same permissions as VSCode
|
||||
- Be cautious with hooks from untrusted sources
|
||||
- Review hook scripts before enabling them
|
||||
- Consider using `.gitignore` to avoid committing sensitive hook logic
|
||||
- Hooks can access all workspace files and environment variables
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep hooks fast** - Aim for <100ms execution time
|
||||
2. **Make context actionable** - Be specific about what the AI should do
|
||||
3. **Use structured prefixes** - Help the AI categorize context
|
||||
4. **Handle errors gracefully** - Always return valid JSON
|
||||
5. **Log for debugging** - Keep logs of hook executions for troubleshooting
|
||||
6. **Test incrementally** - Start with simple hooks and add complexity
|
||||
7. **Document your hooks** - Add comments explaining the purpose and logic
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cline Development Environment Variables
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# Values should be obtained from 1Password shared vault for development
|
||||
|
||||
# ============================================================================
|
||||
# DEVELOPMENT FLAGS
|
||||
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
|
||||
# ============================================================================
|
||||
# IS_DEV=true
|
||||
# CLINE_ENVIRONMENT=local
|
||||
|
||||
# ============================================================================
|
||||
# POSTHOG TELEMETRY (Existing)
|
||||
# ============================================================================
|
||||
# Get these values from 1Password shared vault
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL DEVELOPMENT SETTINGS
|
||||
# ============================================================================
|
||||
# Uncomment and modify as needed for development
|
||||
|
||||
# Multi-root workspace debugging
|
||||
# MULTI_ROOT_TRACE=true
|
||||
|
||||
# gRPC recorder for testing
|
||||
# GRPC_RECORDER_ENABLED=true
|
||||
# GRPC_RECORDER_FILE_NAME=test-recording
|
||||
|
||||
# Test mode
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
# ============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# ============================================================================
|
||||
# 1. Copy this file: cp .env.example .env
|
||||
# 2. Get PostHog keys from 1Password shared vault
|
||||
# 3. Update the values in .env
|
||||
# 4. The .env file is gitignored for security
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4. Cline uses complex prompts so less capable models may not work as expected.
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: plugin-type
|
||||
attributes:
|
||||
@@ -14,6 +14,7 @@ body:
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
@@ -49,7 +50,7 @@ body:
|
||||
attributes:
|
||||
label: Provider/Model
|
||||
description: What provider and model were you using when the issue occurred?
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4, gemini:gemini-2.5-pro-exp-03-25'
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Auto-label Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['JetBrains']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['VS Code']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['CLI']
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
name: Release Standalone CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., v3.32.6)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-13
|
||||
platform: darwin-x64
|
||||
arch: x64
|
||||
- os: macos-14
|
||||
platform: darwin-arm64
|
||||
arch: arm64
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
arch: x64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download Node.js binaries
|
||||
run: npm run download-node
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Build standalone CLI package
|
||||
run: npm run compile-standalone-cli
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Rename package
|
||||
run: |
|
||||
cd dist-standalone
|
||||
mv standalone-cli.zip cline-${{ steps.version.outputs.version }}-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-${{ matrix.platform }}
|
||||
path: dist-standalone/cline-${{ steps.version.outputs.version }}-${{ matrix.platform }}.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Display structure
|
||||
run: ls -R artifacts/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: Cline CLI ${{ steps.version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/cline-darwin-x64/cline-${{ steps.version.outputs.version }}-darwin-x64.tar.gz
|
||||
artifacts/cline-darwin-arm64/cline-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz
|
||||
artifacts/cline-linux-x64/cline-${{ steps.version.outputs.version }}-linux-x64.tar.gz
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
Install Cline CLI with a single command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/cline/cline/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Platform-Specific Downloads
|
||||
|
||||
- **macOS (Intel)**: `cline-${{ steps.version.outputs.version }}-darwin-x64.tar.gz`
|
||||
- **macOS (Apple Silicon)**: `cline-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz`
|
||||
- **Linux (x64)**: `cline-${{ steps.version.outputs.version }}-linux-x64.tar.gz`
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the appropriate package for your platform
|
||||
2. Extract: `tar -xzf cline-*.tar.gz`
|
||||
3. Move to installation directory: `mv cline-* ~/.cline`
|
||||
4. Add to PATH: `export PATH="$HOME/.cline/bin:$PATH"`
|
||||
|
||||
### What's Included
|
||||
|
||||
- ✅ Node.js v22.15.0 (bundled)
|
||||
- ✅ Cline CLI binary
|
||||
- ✅ Cline Host bridge
|
||||
- ✅ Cline Core (TypeScript compiled)
|
||||
- ✅ Ripgrep v14.1.1 (for file searching)
|
||||
- ✅ All dependencies
|
||||
|
||||
### Getting Started
|
||||
|
||||
```bash
|
||||
# Verify installation
|
||||
cline version
|
||||
|
||||
# Sign in
|
||||
cline auth login
|
||||
|
||||
# Get help
|
||||
cline --help
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Installation Guide](https://docs.cline.bot/getting-started/installing-cline)
|
||||
- [CLI Documentation](https://docs.cline.bot/exploring-clines-tools/cline-tools-guide)
|
||||
- [GitHub Repository](https://github.com/cline/cline)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
+83
-152
@@ -1,6 +1,9 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -14,7 +17,45 @@ permissions:
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -33,18 +74,6 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -52,7 +81,6 @@ jobs:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
@@ -68,60 +96,45 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
- name: Type Check
|
||||
run: npm run check-types
|
||||
|
||||
- name: Lint Check
|
||||
run: npm run lint
|
||||
|
||||
- name: Format Check
|
||||
run: npm run format
|
||||
|
||||
# Build the extension before running tests
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
run: npm run pretest
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests (with coverage on Linux)
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Linux" ]; then
|
||||
npm install --no-save nyc
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
else
|
||||
npm run test:unit
|
||||
fi
|
||||
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Integration Tests with Coverage
|
||||
id: extension_coverage
|
||||
continue-on-error: true
|
||||
- name: Unit Tests - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
node ./scripts/test-ci.js 2>&1 | tee extension_coverage.txt
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
|
||||
npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: npm run test:integration
|
||||
|
||||
# Run webview tests with coverage
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_coverage
|
||||
continue-on-error: true
|
||||
id: webview_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
# Ensure coverage dependency is installed
|
||||
npm install --no-save @vitest/coverage-v8
|
||||
npm run test:coverage 2>&1 | tee webview_coverage.txt
|
||||
cd ..
|
||||
# Default the encoding to UTF-8 - It's not the default on Windows
|
||||
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
|
||||
npm run test:coverage
|
||||
|
||||
# Save coverage reports as artifacts (workflow-scoped)
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
# Only upload artifacts on Linux - We only need coverage from one OS
|
||||
@@ -129,27 +142,11 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
extension_coverage.txt
|
||||
webview-ui/webview_coverage.txt
|
||||
coverage-unit/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
# Set the check as failed if any of the tests failed
|
||||
- name: Check for test failures
|
||||
run: |
|
||||
# Check if any of the test steps failed
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ]; then
|
||||
echo "Extension Integration Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
echo "Webview Tests failed, see previous step for test output."
|
||||
fi
|
||||
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-platform-integration:
|
||||
needs: quality-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -159,7 +156,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
@@ -190,8 +187,20 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Compile standalone
|
||||
run: npm run compile-standalone
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Download Node.js binaries
|
||||
run: npm run download-node
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile standalone CLI
|
||||
run: npm run compile-standalone-cli
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
@@ -212,85 +221,6 @@ jobs:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
coverage:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PRs to main branch
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for accurate comparison
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Download coverage artifacts from test job
|
||||
- name: Download Coverage Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: . # Download to root directory to match expected paths
|
||||
|
||||
# Process coverage workflow
|
||||
- name: Process coverage workflow
|
||||
id: coverage
|
||||
run: |
|
||||
# Extract PR number from GITHUB_REF
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
|
||||
|
||||
# Run the coverage workflow from root directory
|
||||
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
|
||||
--base-branch ${{ github.base_ref }} \
|
||||
--pr-number $PR_NUMBER \
|
||||
--repo $GITHUB_REPOSITORY \
|
||||
--token ${{ secrets.GITHUB_TOKEN }} \
|
||||
--verbose
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
qlty:
|
||||
needs: [test, test-platform-integration]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -298,8 +228,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for accurate comparison
|
||||
|
||||
- name: Download unit tests coverage reports
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -328,11 +256,14 @@ jobs:
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
id: download-integration-coverage
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d @- <<EOF
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
+1
-3
@@ -26,6 +26,7 @@ coverage-unit
|
||||
!.github/scripts/coverage/
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
@@ -34,6 +35,3 @@ webview-ui/src/services/grpc-client.ts
|
||||
|
||||
# E2E Tests
|
||||
test-results
|
||||
|
||||
## CLI pre-release ##
|
||||
/cli
|
||||
|
||||
Vendored
+6
@@ -19,6 +19,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -39,6 +40,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -59,6 +61,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -84,6 +87,7 @@
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
@@ -114,6 +118,7 @@
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
@@ -151,6 +156,7 @@
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.7]
|
||||
|
||||
- Add JP and Global inference profile options to AWS Bedrock
|
||||
- Adding Improvements to VSCode multi root workspaces
|
||||
- Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
|
||||
## [3.32.6]
|
||||
|
||||
- Add experimental support for VSCode multi root workspaces
|
||||
- Add Claude Sonnet 4.5 to Claude Code provider
|
||||
- Add Glm 4.6 to Z AI provider
|
||||
|
||||
## [3.32.5]
|
||||
|
||||
- Improve thinking budget slider UI to take up less space
|
||||
- Fix Vercel provider cost note and sign-up url
|
||||
- Fix repeated API error 400 in SAP AI Core provider
|
||||
- Add us-west-1 to Amazon Bedrock regions
|
||||
- Fix OCA provider refresh logic
|
||||
|
||||
## [3.32.4]
|
||||
|
||||
- Add 1m context window support to Claude Sonnet 4.5
|
||||
- Add Claude Sonnet 4.5 to GCP Vertex
|
||||
- Add prompt caching support for OpenRouter accidental `anthropic/claude-4.5-sonnet` model ID
|
||||
|
||||
## [3.32.3]
|
||||
|
||||
- Add Claude Sonnet 4.5 to Bedrock provider
|
||||
- Add Alert banner for new Claude Sonnet 4.5 model
|
||||
|
||||
## [3.32.2]
|
||||
|
||||
- Add Claude Sonnet 4.5 to Cline/OpenRouter/Anthropic providers
|
||||
- Add /task deep link handler
|
||||
|
||||
## [3.32.1]
|
||||
|
||||
- Preserve reasoning traces for Cline/OpenRouter/Anthropic providers to maintain conversation integrity
|
||||
- Add automatically retry on rate limit errors with SAP AI Core provider
|
||||
- Fix Cline accounts using stale id token at refresh response
|
||||
- Minor UI improvements to Settings and Task Header
|
||||
|
||||
## [3.32.0]
|
||||
|
||||
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
|
||||
- Changes to inform Cline about commands that are available on your system
|
||||
|
||||
## [3.31.1]
|
||||
|
||||
- Version bump
|
||||
|
||||
## [3.31.0]
|
||||
|
||||
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
|
||||
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
|
||||
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
|
||||
- Fix Oracle Code Assist provider issues
|
||||
|
||||
## [3.30.3]
|
||||
|
||||
- Add Oracle Code Assist provider
|
||||
|
||||
@@ -74,7 +74,6 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
4. Testing
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `npm run test:ci` to run tests locally
|
||||
|
||||
### Extension
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
@@ -0,0 +1,6 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/cline/cli/pkg/hostbridge"
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline-host",
|
||||
Short: "Cline Host Bridge Service",
|
||||
Long: `A simple host bridge service that provides host operations for Cline Core.`,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
if verbose {
|
||||
log.Println("Shutting down hostbridge server...")
|
||||
}
|
||||
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start server
|
||||
if verbose {
|
||||
log.Printf("Starting Cline Host Bridge on port %d", port)
|
||||
}
|
||||
|
||||
// Run the service
|
||||
if err := service.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to run service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"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/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline [prompt]",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
Start a new task by providing a prompt:
|
||||
cline "Create a new Python script that prints hello world"
|
||||
|
||||
Or run with no arguments to enter interactive mode:
|
||||
cline
|
||||
|
||||
This CLI also provides task management, configuration, and monitoring capabilities.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var instanceAddress string
|
||||
|
||||
// If --address flag not provided, start instance BEFORE getting prompt
|
||||
if !cmd.Flags().Changed("address") {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
}
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance: %w", err)
|
||||
}
|
||||
instanceAddress = instance.Address
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Started instance at %s\n\n", instanceAddress)
|
||||
}
|
||||
|
||||
// Set up cleanup on exit
|
||||
defer func() {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("\nCleaning up instance...")
|
||||
}
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if user has credentials configured
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
// Create renderer for welcome messages
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
|
||||
markdown := "## hey there! looks like you're new here. let's get you set up"
|
||||
rendered := renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
|
||||
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
|
||||
return fmt.Errorf("auth setup failed: %w", err)
|
||||
}
|
||||
|
||||
// Re-check after auth wizard
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
|
||||
}
|
||||
|
||||
markdown = "## ✓ setup complete, you can now use the cline cli"
|
||||
rendered = renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
}
|
||||
} else {
|
||||
// User specified --address flag, use that
|
||||
instanceAddress = coreAddress
|
||||
}
|
||||
|
||||
var prompt string
|
||||
|
||||
// If args provided, use as prompt
|
||||
if len(args) > 0 {
|
||||
prompt = strings.Join(args, " ")
|
||||
} else {
|
||||
// Show interactive input to get prompt
|
||||
var err error
|
||||
prompt, err = promptForInitialTask()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
}
|
||||
|
||||
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
|
||||
Images: images,
|
||||
Files: files,
|
||||
Workspaces: workspaces,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
|
||||
// Task creation flags (only apply when using root command with prompt)
|
||||
rootCmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
rootCmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
rootCmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan")
|
||||
rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)")
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewConfigCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
rootCmd.AddCommand(cli.NewTaskSendCommand())
|
||||
rootCmd.AddCommand(cli.NewLogsCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask() (string, error) {
|
||||
var prompt string
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Start a new Cline task").
|
||||
Description("What would you like Cline to help you with?").
|
||||
Placeholder("e.g., Create a REST API with authentication...").
|
||||
Lines(5).
|
||||
Value(&prompt),
|
||||
),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(prompt), nil
|
||||
}
|
||||
|
||||
// isUserReadyToUse checks if the user has completed initial setup
|
||||
// Returns true if welcomeViewCompleted flag is set OR user is authenticated
|
||||
// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid)
|
||||
func isUserReadyToUse(ctx context.Context, instanceAddress string) bool {
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get state
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
stateMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check 1: welcomeViewCompleted flag
|
||||
if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check 2: Is user authenticated? (matches extension's || user?.uid check)
|
||||
if userInfo, ok := stateMap["userInfo"].(map[string]interface{}); ok {
|
||||
if uid, ok := userInfo["uid"].(string); ok && uid != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// 2. Multi-instance start: default_instance remains the first started.
|
||||
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start first instance and wait healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out1 := listInstancesJSON(ctx, t)
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out2 := listInstancesJSON(ctx, t)
|
||||
if len(out2.CoreInstances) < 2 {
|
||||
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
|
||||
}
|
||||
|
||||
// Default should remain the first started address
|
||||
if out2.DefaultInstance != firstAddr {
|
||||
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Remove the settings directory entirely (which contains locks.db)
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
|
||||
if err := os.RemoveAll(settingsDir); err != nil {
|
||||
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
|
||||
}
|
||||
|
||||
// Listing should succeed and return empty results
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 0 {
|
||||
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
|
||||
}
|
||||
|
||||
// Ensure cli-default-instance.json not present
|
||||
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if _, err := os.Stat(defPath); err == nil {
|
||||
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
longTimeout = 60 * time.Second
|
||||
pollInterval = 250 * time.Millisecond
|
||||
instancesBinRel = "../bin/cline"
|
||||
)
|
||||
|
||||
func repoAwareBinPath(t *testing.T) string {
|
||||
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd error: %v", err)
|
||||
}
|
||||
// cli/e2e -> cli/bin/cline
|
||||
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func setTempClineDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
clineDir := filepath.Join(dir, ".cline")
|
||||
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir clineDir: %v", err)
|
||||
}
|
||||
t.Setenv("CLINE_DIR", clineDir)
|
||||
return clineDir
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
||||
t.Helper()
|
||||
bin := repoAwareBinPath(t)
|
||||
|
||||
// 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 {
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
cmd.Dir = repoRoot
|
||||
}
|
||||
// propagate env including CLINE_DIR
|
||||
cmd.Env = os.Environ()
|
||||
outB, errB := &strings.Builder{}, &strings.Builder{}
|
||||
cmd.Stdout = outB
|
||||
cmd.Stderr = errB
|
||||
err := cmd.Run()
|
||||
exit := 0
|
||||
if err != nil {
|
||||
// Extract exit code if possible
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
exit = -1
|
||||
}
|
||||
}
|
||||
return outB.String(), errB.String(), exit
|
||||
}
|
||||
|
||||
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
out, errOut, exit := runCLI(ctx, t, args...)
|
||||
if exit != 0 {
|
||||
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
||||
t.Helper()
|
||||
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
||||
_ = mustRunCLI(ctx, t, "instance", "list")
|
||||
|
||||
// Read from SQLite locks database to build structured output
|
||||
clineDir := getClineDir(t)
|
||||
|
||||
// Load default instance from settings file
|
||||
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
||||
|
||||
// Load instances from SQLite
|
||||
instances := readInstancesFromSQLite(t, clineDir)
|
||||
|
||||
return common.InstancesOutput{
|
||||
DefaultInstance: defaultInstance,
|
||||
CoreInstances: instances,
|
||||
}
|
||||
}
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
return common.CoreInstanceInfo{}, false
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
ok, msg := cond()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waitFor timeout: %s", msg)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Waiting for gRPC health check on %s...", addr)
|
||||
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if common.IsInstanceHealthy(ctx, addr) {
|
||||
return true, ""
|
||||
}
|
||||
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
||||
})
|
||||
|
||||
t.Logf("gRPC health check passed for %s", addr)
|
||||
}
|
||||
|
||||
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if hasAddress(out, addr) {
|
||||
return false, fmt.Sprintf("address %s still present", addr)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
if clineDir == "" {
|
||||
t.Fatalf("CLINE_DIR not set")
|
||||
}
|
||||
return clineDir
|
||||
}
|
||||
|
||||
// isPortInUse checks if a port is currently in use by any process
|
||||
func isPortInUse(port int) bool {
|
||||
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return true // Port is in use
|
||||
}
|
||||
conn.Close()
|
||||
return false // Port is free
|
||||
}
|
||||
|
||||
// waitForPortClosed waits for a port to become free (no process listening)
|
||||
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(port) {
|
||||
return false, fmt.Sprintf("port %d still in use", port)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// waitForPortsClosed waits for both core and host ports to become free
|
||||
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
waitFor(t, timeout, func() (bool, string) {
|
||||
if isPortInUse(corePort) {
|
||||
return false, fmt.Sprintf("core port %d still in use", corePort)
|
||||
}
|
||||
if isPortInUse(hostPort) {
|
||||
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
||||
}
|
||||
return true, ""
|
||||
})
|
||||
}
|
||||
|
||||
// findAndKillHostProcess finds and kills any process listening on the host port
|
||||
// This is used to clean up dangling host processes after SIGKILL tests
|
||||
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
||||
t.Helper()
|
||||
// Use lsof to find process listening on the host port
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No process found on port - that's fine
|
||||
return
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return
|
||||
}
|
||||
|
||||
if pid > 0 {
|
||||
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
||||
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
||||
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
||||
func getPIDByPort(t *testing.T, port int) int {
|
||||
t.Helper()
|
||||
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0 // Process not found
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
||||
return 0
|
||||
}
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
||||
func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Initialize global config to access registry
|
||||
clineDir := os.Getenv("CLINE_DIR")
|
||||
if clineDir == "" {
|
||||
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
cfg := &global.GlobalConfig{
|
||||
ConfigPath: clineDir,
|
||||
}
|
||||
|
||||
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
||||
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// Call GetProcessInfo RPC
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
return int(processInfo.ProcessId)
|
||||
}
|
||||
|
||||
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
||||
func getCorePIDViaLsof(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
_, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid address format %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: invalid port in address %s", address)
|
||||
return 0
|
||||
}
|
||||
|
||||
return getPIDByPort(t, port)
|
||||
}
|
||||
|
||||
// getCorePID returns the PID of the cline-core process for the given address
|
||||
// Uses RPC first, falls back to lsof if RPC fails
|
||||
func getCorePID(t *testing.T, address string) int {
|
||||
t.Helper()
|
||||
|
||||
// Try RPC first (preferred method)
|
||||
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
||||
return pid
|
||||
}
|
||||
|
||||
// Fall back to lsof if RPC fails
|
||||
return getCorePIDViaLsof(t, address)
|
||||
}
|
||||
|
||||
// getHostPID returns the PID of the cline-host process for the given host port
|
||||
func getHostPID(t *testing.T, hostPort int) int {
|
||||
t.Helper()
|
||||
return getPIDByPort(t, hostPort)
|
||||
}
|
||||
|
||||
// contains reports whether slice has the target string.
|
||||
func contains(slice []string, target string) bool {
|
||||
for _, s := range slice {
|
||||
if s == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain validates required artifacts exist before running E2E tests.
|
||||
// It does NOT build artifacts. Build manually via:
|
||||
//
|
||||
// npm run compile-standalone
|
||||
// npm run compile-cli
|
||||
func TestMain(m *testing.M) {
|
||||
// Determine repo root from cli/e2e
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
||||
|
||||
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
|
||||
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
|
||||
|
||||
missing := []string{}
|
||||
if _, err := os.Stat(cliBin); err != nil {
|
||||
missing = append(missing, cliBin)
|
||||
}
|
||||
if _, err := os.Stat(coreJS); err != nil {
|
||||
missing = append(missing, coreJS)
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
if testing.Short() {
|
||||
// Optional quality-of-life: allow skipping with -short when artifacts are absent
|
||||
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
|
||||
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Query instance locks
|
||||
query := common.SelectInstanceLockHoldersAscSQL
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to query instance locks: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []common.CoreInstanceInfo
|
||||
for rows.Next() {
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
|
||||
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to scan lock row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// readDefaultInstanceFromSettings reads the default instance from the settings file
|
||||
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
t.Helper()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Logf("Warning: Failed to read default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var tmp struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
t.Logf("Warning: Failed to parse default instance file: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return tmp.DefaultInstance
|
||||
}
|
||||
|
||||
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize database schema for testing
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS locks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
held_by TEXT NOT NULL,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
|
||||
lock_target TEXT NOT NULL,
|
||||
locked_at INTEGER NOT NULL,
|
||||
UNIQUE(lock_type, lock_target)
|
||||
);
|
||||
`
|
||||
createIndexesSQL := `
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(createIndexesSQL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert the remote instance
|
||||
hostAddress := "remote.example.com:0"
|
||||
if hostPort != 0 {
|
||||
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
|
||||
}
|
||||
|
||||
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
|
||||
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
|
||||
return err
|
||||
}
|
||||
|
||||
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
|
||||
var count int
|
||||
err = db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
t.Logf("Failed to query database: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
|
||||
func TestStartAndList(t *testing.T) {
|
||||
clineDir := setTempClineDir(t)
|
||||
t.Logf("Using temp CLINE_DIR: %s", clineDir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("Starting new instance...")
|
||||
// Start a new instance
|
||||
startOutput := mustRunCLI(ctx, t, "instance", "new")
|
||||
t.Logf("Instance start output: %s", startOutput)
|
||||
|
||||
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 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
t.Logf("Address %s is now healthy", addr)
|
||||
|
||||
t.Logf("Checking default instance configuration...")
|
||||
// Default should be set to the new instance.
|
||||
out = listInstancesJSON(ctx, t)
|
||||
t.Logf("Default instance: %s", out.DefaultInstance)
|
||||
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
}
|
||||
|
||||
// TestTaskNewDefault ensures tasks route to default instance.
|
||||
func TestTaskNewDefault(t *testing.T) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start one instance and wait for healthy
|
||||
_ = mustRunCLI(ctx, t, "instance", "new")
|
||||
out := listInstancesJSON(ctx, t)
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
_ = 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) {
|
||||
_ = setTempClineDir(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Start two instances for testing both graceful and crash scenarios
|
||||
_ = 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))
|
||||
}
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
|
||||
findAndKillHostProcess(t, crashTarget.HostPort())
|
||||
|
||||
// Verify both ports are now free
|
||||
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
|
||||
}
|
||||
|
||||
// itoa is a small helper for readability
|
||||
func itoa(i int) string {
|
||||
return strconvItoa(i)
|
||||
}
|
||||
|
||||
// minimal inline int->string to avoid extra imports in helpers
|
||||
func strconvItoa(i int) string {
|
||||
// simple fast path
|
||||
return fmtInt(i)
|
||||
}
|
||||
|
||||
func fmtInt(i int) string {
|
||||
// allocate small buffer; ints here are short
|
||||
return (func(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
})(i)
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.7.0
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.32.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
)
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
||||
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return auth.HandleAuthCommand(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
// Cline provider specific code
|
||||
|
||||
func HandleClineAuth(ctx context.Context) error {
|
||||
verboseLog("Authenticating with Cline...")
|
||||
|
||||
// Check if already authenticated
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
// Perform sign in
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
verboseLog("✓ You are signed in!")
|
||||
|
||||
|
||||
// Configure default Cline model after successful authentication
|
||||
if err := configureDefaultClineModel(ctx); err != nil {
|
||||
fmt.Printf("Warning: Could not configure default Cline model: %v\n", err)
|
||||
fmt.Println("You can configure a model later with 'cline auth' and selecting 'Change Cline model'")
|
||||
}
|
||||
|
||||
// Return to main auth menu after successful authentication
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
var confirm bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("You are already signed in to Cline.").
|
||||
Description("Would you like to sign out?").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if confirm {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe to auth updates before initiating login
|
||||
verboseLog("Subscribing to auth status updates...")
|
||||
listener, err := NewAuthStatusListener(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to subscribe to auth updates: %v", err)
|
||||
return fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
defer listener.Stop()
|
||||
|
||||
if err := listener.Start(); err != nil {
|
||||
verboseLog("Failed to start auth listener: %v", err)
|
||||
return fmt.Errorf("failed to start auth listener: %w", err)
|
||||
}
|
||||
|
||||
// Initiate login (opens browser with callback URL from cline-core's AuthHandler)
|
||||
verboseLog("Initiating login...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to initiate login: %v", err)
|
||||
return fmt.Errorf("failed to initiate login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n Opening browser for authentication...")
|
||||
fmt.Println(" Waiting for you to complete authentication in your browser...")
|
||||
fmt.Println(" (This may take a few moments. Timeout: 5 minutes)")
|
||||
|
||||
// Wait for auth status update confirming success
|
||||
verboseLog("Waiting for authentication to complete...")
|
||||
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
|
||||
verboseLog("Authentication failed or timed out: %v", err)
|
||||
fmt.Println("\n Authentication failed or timed out.")
|
||||
fmt.Println(" Please try again with 'cline auth'")
|
||||
return err
|
||||
}
|
||||
|
||||
// Only NOW set the session flag after confirmed authentication
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
verboseLog("Session is already authenticated")
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Verifying authentication with server...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to get client for auth check: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
if err == nil {
|
||||
// Update session variable for future fast-path checks
|
||||
verboseLog("Server verification successful, updating session flag")
|
||||
isSessionAuthenticated = true
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Server verification failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleChangeClineModel allows Cline-authenticated users to change their Cline model selection. Hidden when not authenticated.
|
||||
func HandleChangeClineModel(ctx context.Context) error {
|
||||
// Ensure user is authenticated
|
||||
if !IsAuthenticated(ctx) {
|
||||
return fmt.Errorf("you must be authenticated with Cline to change models. Run 'cline auth' to sign in")
|
||||
}
|
||||
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Launch Cline model selection
|
||||
return SelectClineModel(ctx, manager)
|
||||
}
|
||||
|
||||
// configureDefaultClineModel configures the default Cline model after authentication
|
||||
func configureDefaultClineModel(ctx context.Context) error {
|
||||
verboseLog("Configuring default Cline model...")
|
||||
|
||||
// Create task manager
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Set default Cline model
|
||||
return SetDefaultClineModel(ctx, manager)
|
||||
}
|
||||
|
||||
// HandleSelectOrganization allows Cline-authenticated users to select which organization to use
|
||||
func HandleSelectOrganization(ctx context.Context) error {
|
||||
// Ensure user is authenticated
|
||||
if !IsAuthenticated(ctx) {
|
||||
return fmt.Errorf("you must be authenticated with Cline to select an organization. Run 'cline auth' to sign in")
|
||||
}
|
||||
|
||||
// Get client
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Fetch user organizations
|
||||
orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations := orgsResponse.GetOrganizations()
|
||||
if len(organizations) == 0 {
|
||||
fmt.Println("You don't have any organizations yet.")
|
||||
fmt.Println("Visit https://app.cline.bot/dashboard to create an organization.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Build options list: Personal + Organizations
|
||||
var options []huh.Option[string]
|
||||
options = append(options, huh.NewOption("Personal", "personal"))
|
||||
|
||||
for _, org := range organizations {
|
||||
displayName := org.Name
|
||||
// Show active indicator
|
||||
if org.Active {
|
||||
displayName = fmt.Sprintf("%s (active)", displayName)
|
||||
}
|
||||
options = append(options, huh.NewOption(displayName, org.OrganizationId))
|
||||
}
|
||||
|
||||
options = append(options, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select which account to use").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select organization: %w", err)
|
||||
}
|
||||
|
||||
if selected == "cancel" {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Set the organization
|
||||
var orgId *string
|
||||
if selected != "personal" {
|
||||
orgId = &selected
|
||||
}
|
||||
|
||||
req := &cline.UserOrganizationUpdateRequest{
|
||||
OrganizationId: orgId,
|
||||
}
|
||||
|
||||
if _, err := client.Account.SetUserOrganization(ctx, req); err != nil {
|
||||
return fmt.Errorf("failed to set organization: %w", err)
|
||||
}
|
||||
|
||||
if selected == "personal" {
|
||||
fmt.Println("✓ Switched to personal account")
|
||||
} else {
|
||||
// Find the org name to display
|
||||
var orgName string
|
||||
for _, org := range organizations {
|
||||
if org.OrganizationId == selected {
|
||||
orgName = org.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Printf("✓ Switched to organization: %s\n", orgName)
|
||||
}
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// AuthAction represents the type of authentication action
|
||||
type AuthAction string
|
||||
|
||||
const (
|
||||
AuthActionClineLogin AuthAction = "cline_login"
|
||||
AuthActionBYOSetup AuthAction = "provider_setup"
|
||||
AuthActionChangeClineModel AuthAction = "change_cline_model"
|
||||
AuthActionSelectOrganization AuthAction = "select_organization"
|
||||
AuthActionSelectProvider AuthAction = "select_provider"
|
||||
AuthActionExit AuthAction = "exit_wizard"
|
||||
)
|
||||
|
||||
// Cline Auth Menu
|
||||
// Example Layout
|
||||
//
|
||||
// ┃ Cline Account: <authenticated/not authenticated>
|
||||
// ┃ Active Provider: <provider name or none configured>
|
||||
// ┃ Active Model: <model name or none configured>
|
||||
// ┃
|
||||
// ┃ What would you like to do?
|
||||
// ┃ Change Cline model (only if authenticated) - hidden if not authenticated
|
||||
// ┃ Authenticate with Cline account / Sign out of Cline - changes based on auth status
|
||||
// ┃ Select active provider (Cline or BYO) - always shown. Used to switch between Cline and BYO providers
|
||||
// ┃ Configure API provider - always shown. Launches provider setup wizard
|
||||
// ┃ Exit authorization wizard - always shown. Exits the auth menu
|
||||
|
||||
// Main entry point for handling the `cline auth` command
|
||||
// HandleAuthCommand routes the auth command based on the number of arguments
|
||||
func HandleAuthCommand(ctx context.Context, args []string) error {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// No args: Show menu (ShowAuthMenuNoArgs)
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
case 1:
|
||||
// One arg: Provider ID only, prompt for API key
|
||||
return QuickAPISetup(args[0], "")
|
||||
case 2:
|
||||
// Two args: Provider ID and API key
|
||||
return QuickAPISetup(args[0], args[1])
|
||||
default:
|
||||
return fmt.Errorf("quick BYO API setup is currently stubbed - not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAuthMenuNoArgs prepares the auth menu when no arguments are provided
|
||||
func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
// Ensure a default instance exists BEFORE trying to create task manager
|
||||
// This is necessary because createTaskManager() needs a default instance to connect to
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
|
||||
// Check if Cline is authenticated
|
||||
isClineAuth := IsAuthenticated(ctx)
|
||||
|
||||
// Get current provider config for display
|
||||
var currentProvider string
|
||||
var currentModel string
|
||||
if manager, err := createTaskManager(ctx); err == nil {
|
||||
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = getProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch organizations if authenticated
|
||||
var hasOrganizations bool
|
||||
if isClineAuth {
|
||||
if client, err := global.GetDefaultClient(ctx); err == nil {
|
||||
if orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
hasOrganizations = len(orgsResponse.GetOrganizations()) > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action, err := ShowAuthMenuWithStatus(isClineAuth, hasOrganizations, currentProvider, currentModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case AuthActionClineLogin:
|
||||
return HandleClineAuth(ctx)
|
||||
case AuthActionBYOSetup:
|
||||
return HandleAPIProviderSetup(ctx)
|
||||
case AuthActionChangeClineModel:
|
||||
return HandleChangeClineModel(ctx)
|
||||
case AuthActionSelectOrganization:
|
||||
return HandleSelectOrganization(ctx)
|
||||
case AuthActionSelectProvider:
|
||||
return HandleSelectProvider(ctx)
|
||||
case AuthActionExit:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid action")
|
||||
}
|
||||
}
|
||||
|
||||
// ShowAuthMenuWithStatus displays the main auth menu with Cline + provider status
|
||||
func ShowAuthMenuWithStatus(isClineAuthenticated bool, hasOrganizations bool, currentProvider, currentModel string) (AuthAction, error) {
|
||||
var action AuthAction
|
||||
var options []huh.Option[AuthAction]
|
||||
|
||||
// Build menu options based on authentication status
|
||||
if isClineAuthenticated {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Change Cline model", AuthActionChangeClineModel),
|
||||
}
|
||||
|
||||
// Add organization selection if user has organizations
|
||||
if hasOrganizations {
|
||||
options = append(options, huh.NewOption("Select organization", AuthActionSelectOrganization))
|
||||
}
|
||||
|
||||
options = append(options,
|
||||
huh.NewOption("Sign out of Cline", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure API provider", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
)
|
||||
} else {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Authenticate with Cline account", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure API provider", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
}
|
||||
}
|
||||
|
||||
// Determine menu title based on status
|
||||
var title string
|
||||
|
||||
// Always show Cline authentication status
|
||||
if isClineAuthenticated {
|
||||
title = "Cline Account: \033[32m✓\033[0m Authenticated\n"
|
||||
} else {
|
||||
title = "Cline Account: \033[31m✗\033[0m Not authenticated\n"
|
||||
}
|
||||
|
||||
// Show active provider and model if configured (regardless of Cline auth status)
|
||||
// ANSI color codes: Normal intensity = \033[22m, White = \033[37m, Reset = \033[0m
|
||||
if currentProvider != "" && currentModel != "" {
|
||||
title += fmt.Sprintf("Active Provider: \033[22m\033[37m%s\033[0m\nActive Model: \033[22m\033[37m%s\033[0m\n", currentProvider, currentModel)
|
||||
}
|
||||
|
||||
// Always end with a huh?
|
||||
title += "\nWhat would you like to do?"
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[AuthAction]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// HandleAPIProviderSetup launches the API provider configuration wizard
|
||||
func HandleAPIProviderSetup(ctx context.Context) error {
|
||||
wizard, err := NewProviderWizard(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create provider wizard: %w", err)
|
||||
}
|
||||
|
||||
return wizard.Run()
|
||||
}
|
||||
|
||||
// HandleSelectProvider allows users to switch between Cline provider and BYO providers
|
||||
func HandleSelectProvider(ctx context.Context) error {
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Detect all providers with valid configurations (is an API key present)
|
||||
availableProviders, err := DetectAllConfiguredProviders(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect configured providers: %w", err)
|
||||
}
|
||||
|
||||
// Build list of available providers
|
||||
var providerOptions []huh.Option[string]
|
||||
var providerMapping = make(map[string]cline.ApiProvider)
|
||||
|
||||
// Add each configured provider to the selection menu
|
||||
for _, provider := range availableProviders {
|
||||
providerName := getProviderDisplayName(provider)
|
||||
providerKey := fmt.Sprintf("provider_%d", provider)
|
||||
providerOptions = append(providerOptions, huh.NewOption(providerName, providerKey))
|
||||
providerMapping[providerKey] = provider
|
||||
}
|
||||
|
||||
if len(providerOptions) == 0 {
|
||||
fmt.Println("No providers available. Please configure a provider first.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
if len(providerOptions) == 1 {
|
||||
fmt.Println("Only one provider is configured. Configure another provider to switch between them.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
providerOptions = append(providerOptions, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select which provider to use").
|
||||
Options(providerOptions...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selected == "cancel" {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Get the selected provider
|
||||
selectedProvider := providerMapping[selected]
|
||||
|
||||
// Apply the selected provider
|
||||
if selectedProvider == cline.ApiProvider_CLINE {
|
||||
// Configure Cline as the active provider
|
||||
return SelectClineModel(ctx, manager)
|
||||
} else {
|
||||
// Switch to the selected BYO provider
|
||||
return SwitchToBYOProvider(ctx, manager, selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// createTaskManager is a helper to create a task manager (avoids import cycles)
|
||||
func createTaskManager(ctx context.Context) (*task.Manager, error) {
|
||||
return task.NewManagerForDefault(ctx)
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// AuthStatusListener manages subscription to auth status updates
|
||||
type AuthStatusListener struct {
|
||||
stream cline.AccountService_SubscribeToAuthStatusUpdateClient
|
||||
updatesCh chan *cline.AuthState
|
||||
errCh chan error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewAuthStatusListener creates a new auth status listener
|
||||
func NewAuthStatusListener(parentCtx context.Context) (*AuthStatusListener, error) {
|
||||
client, err := global.GetDefaultClient(parentCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Create cancellable context
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
|
||||
// Subscribe to auth status updates
|
||||
stream, err := client.Account.SubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
|
||||
return &AuthStatusListener{
|
||||
stream: stream,
|
||||
updatesCh: make(chan *cline.AuthState, 10),
|
||||
errCh: make(chan error, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening to the auth status update stream
|
||||
func (l *AuthStatusListener) Start() error {
|
||||
verboseLog("Starting auth status listener...")
|
||||
|
||||
go l.readStream()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStream reads from the gRPC stream and forwards messages to channels
|
||||
func (l *AuthStatusListener) readStream() {
|
||||
defer close(l.updatesCh)
|
||||
defer close(l.errCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
verboseLog("Auth listener context cancelled")
|
||||
return
|
||||
default:
|
||||
state, err := l.stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
verboseLog("Auth status stream closed")
|
||||
return
|
||||
}
|
||||
verboseLog("Error reading from auth status stream: %v", err)
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
case <-l.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
verboseLog("Received auth state update: user=%v", state.User != nil)
|
||||
|
||||
select {
|
||||
case l.updatesCh <- state:
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForAuthentication blocks until authentication succeeds or timeout occurs
|
||||
func (l *AuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
|
||||
verboseLog("Waiting for authentication (timeout: %v)...", timeout)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("authentication timeout after %v - please try again", timeout)
|
||||
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("authentication cancelled")
|
||||
|
||||
case err := <-l.errCh:
|
||||
return fmt.Errorf("authentication stream error: %w", err)
|
||||
|
||||
case state := <-l.updatesCh:
|
||||
if isAuthenticated(state) {
|
||||
verboseLog("Authentication successful!")
|
||||
return nil
|
||||
}
|
||||
verboseLog("Received auth update but not authenticated yet...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop closes the stream and cleans up resources
|
||||
func (l *AuthStatusListener) Stop() {
|
||||
verboseLog("Stopping auth status listener...")
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
// isAuthenticated checks if AuthState indicates successful authentication
|
||||
func isAuthenticated(state *cline.AuthState) bool {
|
||||
return state != nil && state.User != nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
|
||||
// QuickAPISetup performs quick provider setup with provider ID and optional API key
|
||||
func QuickAPISetup(providerID, apiKey string) error {
|
||||
fmt.Println("Quick BYO API setup is currently stubbed - not yet implemented.")
|
||||
fmt.Printf("Requested provider: %s\n", providerID)
|
||||
if apiKey != "" {
|
||||
fmt.Println("Provided API key:", "<jk redacted>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package auth
|
||||
@@ -0,0 +1,141 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// DefaultClineModelID is the default model ID for Cline provider.
|
||||
// Cline uses OpenRouter-compatible model IDs.
|
||||
const DefaultClineModelID = "anthropic/claude-sonnet-4.5"
|
||||
|
||||
// FetchClineModels fetches available Cline models from Cline Core.
|
||||
// Note: Cline provider uses OpenRouter-compatible API and model format.
|
||||
// The models are fetched using the same method as OpenRouter.
|
||||
func FetchClineModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Fetching Cline models (using OpenRouter-compatible API)")
|
||||
}
|
||||
|
||||
// Cline uses OpenRouter model fetching
|
||||
models, err := FetchOpenRouterModels(ctx, manager)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// GetClineModelInfo retrieves information for a specific Cline model.
|
||||
func GetClineModelInfo(modelID string, models map[string]*cline.OpenRouterModelInfo) (*cline.OpenRouterModelInfo, error) {
|
||||
modelInfo, exists := models[modelID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("model %s not found", modelID)
|
||||
}
|
||||
return modelInfo, nil
|
||||
}
|
||||
|
||||
// SetDefaultClineModel configures the default Cline model after authentication.
|
||||
// This is called automatically after successful Cline sign-in.
|
||||
func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch available models
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
// If we can't fetch models, we'll use the default without model info
|
||||
fmt.Printf("Warning: Could not fetch Cline models: %v\n", err)
|
||||
fmt.Printf("Using default model: %s\n", DefaultClineModelID)
|
||||
return applyDefaultClineModel(ctx, manager, nil)
|
||||
}
|
||||
|
||||
// Check if default model is available
|
||||
modelInfo, err := GetClineModelInfo(DefaultClineModelID, models)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Default model not found: %v\n", err)
|
||||
// Try to use any available model
|
||||
for modelID := range models {
|
||||
fmt.Printf("Using available model: %s\n", modelID)
|
||||
return applyClineModelConfiguration(ctx, manager, modelID, models[modelID])
|
||||
}
|
||||
return fmt.Errorf("no usable Cline models found")
|
||||
}
|
||||
|
||||
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectClineModel presents a menu to select a Cline model and applies the configuration.
|
||||
func SelectClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch models (uses OpenRouter-compatible format)
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
// Convert to interface map for generic utilities
|
||||
modelMap := ConvertOpenRouterModelsToInterface(models)
|
||||
|
||||
// Get model IDs as a sorted list
|
||||
modelIDs := ConvertModelsMapToSlice(modelMap)
|
||||
|
||||
// Display selection menu
|
||||
selectedModelID, err := DisplayModelSelectionMenu(modelIDs, "Cline")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Get the selected model info
|
||||
modelInfo := models[selectedModelID]
|
||||
|
||||
// Apply the configuration
|
||||
if err := applyClineModelConfiguration(ctx, manager, selectedModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Return to main auth menu after model selection
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// applyClineModelConfiguration applies a Cline model configuration to both Act and Plan modes using UpdateProviderPartial.
|
||||
// Cline uses OpenRouter-compatible model format.
|
||||
func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, modelID string, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
provider := cline.ApiProvider_CLINE
|
||||
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(ctx, manager, provider, updates, true)
|
||||
}
|
||||
|
||||
func applyDefaultClineModel(ctx context.Context, manager *task.Manager, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setWelcomeViewCompletedWithManager(ctx context.Context, manager *task.Manager) error {
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// FetchOpenRouterModels fetches available OpenRouter models from Cline Core
|
||||
func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOpenRouterModels(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
req := &cline.OpenAiModelsRequest{
|
||||
BaseUrl: baseURL,
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.RefreshOpenAiModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenAI models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// FetchOllamaModels fetches available Ollama models from Cline Core
|
||||
// Takes the base URL (empty string for default) and returns a list of model IDs
|
||||
func FetchOllamaModels(ctx context.Context, manager *task.Manager, baseURL string) ([]string, error) {
|
||||
req := &cline.StringRequest{
|
||||
Value: baseURL,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.GetOllamaModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Ollama models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// DisplayModelSelectionMenu shows an interactive menu for selecting a model from a list.
|
||||
// Models are displayed alphabetically. Uses model ID as the option value to avoid
|
||||
// index-based bugs when list order changes.
|
||||
// Returns the selected model ID.
|
||||
func DisplayModelSelectionMenu(models []string, providerName string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available for selection")
|
||||
}
|
||||
|
||||
// Use model ID as the value (not index) to avoid positional coupling bugs
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models))
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Select a %s model", providerName)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// ConvertModelsMapToSlice converts a map of models to a sorted slice of model IDs.
|
||||
// This is useful for displaying models in a consistent order in UI components.
|
||||
func ConvertModelsMapToSlice(models map[string]interface{}) []string {
|
||||
result := make([]string, 0, len(models))
|
||||
for modelID := range models {
|
||||
result = append(result, modelID)
|
||||
}
|
||||
|
||||
// Sort alphabetically for consistent display
|
||||
sort.Strings(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map.
|
||||
// This allows OpenRouter and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(models))
|
||||
for k, v := range models {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getTerminalHeight returns the terminal height (rows)
|
||||
func getTerminalHeight() int {
|
||||
_, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || height <= 0 {
|
||||
return 25 // safe fallback for non-TTY or errors
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
// calculateSelectHeight computes appropriate height for Select component
|
||||
// Reserves space for title, search UI, and margins
|
||||
func calculateSelectHeight() int {
|
||||
height := getTerminalHeight()
|
||||
// Reserve ~10 rows for UI chrome (title, search, margins)
|
||||
visibleRows := height - 10
|
||||
// Clamp between 8 (minimum usable) and 25 (maximum before unwieldy)
|
||||
if visibleRows < 8 {
|
||||
return 8
|
||||
}
|
||||
if visibleRows > 25 {
|
||||
return 25
|
||||
}
|
||||
return visibleRows
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/cline/cli/pkg/generated"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// SupportsStaticModelList returns true if the provider has a predefined static model list
|
||||
func SupportsStaticModelList(provider cline.ApiProvider) bool {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this provider has static models defined
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Return true if provider has models and isn't dynamic-only
|
||||
// (Dynamic providers like OpenRouter/OpenAI/Ollama fetch from API)
|
||||
return len(def.Models) > 0 && !def.HasDynamicModels
|
||||
}
|
||||
|
||||
// FetchStaticModels retrieves the static model list for a provider from generated definitions
|
||||
// Returns a sorted list of model IDs and a map of model IDs to their info
|
||||
func FetchStaticModels(provider cline.ApiProvider) ([]string, map[string]generated.ModelInfo, error) {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return nil, nil, fmt.Errorf("unknown provider enum: %v", provider)
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get provider definition: %w", err)
|
||||
}
|
||||
|
||||
if len(def.Models) == 0 {
|
||||
return nil, nil, fmt.Errorf("no models defined for provider %s", providerID)
|
||||
}
|
||||
|
||||
// Extract model IDs and sort them
|
||||
modelIDs := make([]string, 0, len(def.Models))
|
||||
for modelID := range def.Models {
|
||||
modelIDs = append(modelIDs, modelID)
|
||||
}
|
||||
sort.Strings(modelIDs)
|
||||
|
||||
return modelIDs, def.Models, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelForProvider returns the default model ID for a provider if one is defined
|
||||
func GetDefaultModelForProvider(provider cline.ApiProvider) string {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return def.DefaultModelID
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// BYOProviderOption represents a selectable BYO (bring-your-own) provider option
|
||||
type BYOProviderOption struct {
|
||||
Name string
|
||||
Provider cline.ApiProvider
|
||||
}
|
||||
|
||||
// GetBYOProviderList returns the list of supported BYO providers for CLI configuration.
|
||||
// This list excludes Cline provider which is handled separately.
|
||||
func GetBYOProviderList() []BYOProviderOption {
|
||||
return []BYOProviderOption{
|
||||
{Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC},
|
||||
{Name: "OpenAI", Provider: cline.ApiProvider_OPENAI},
|
||||
{Name: "OpenAI Native", Provider: cline.ApiProvider_OPENAI_NATIVE},
|
||||
{Name: "OpenRouter", Provider: cline.ApiProvider_OPENROUTER},
|
||||
{Name: "X AI (Grok)", Provider: cline.ApiProvider_XAI},
|
||||
{Name: "AWS Bedrock", Provider: cline.ApiProvider_BEDROCK},
|
||||
{Name: "Google Gemini", Provider: cline.ApiProvider_GEMINI},
|
||||
{Name: "Ollama", Provider: cline.ApiProvider_OLLAMA},
|
||||
{Name: "Cerebras", Provider: cline.ApiProvider_CEREBRAS},
|
||||
}
|
||||
}
|
||||
|
||||
// SelectBYOProvider displays a menu for selecting a BYO provider.
|
||||
func SelectBYOProvider() (cline.ApiProvider, error) {
|
||||
providers := GetBYOProviderList()
|
||||
var selectedIndex int
|
||||
|
||||
options := make([]huh.Option[int], len(providers)+1)
|
||||
for i, provider := range providers {
|
||||
options[i] = huh.NewOption(provider.Name, i)
|
||||
}
|
||||
options[len(providers)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select an API provider").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return 0, fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return 0, fmt.Errorf("provider selection cancelled")
|
||||
}
|
||||
|
||||
return providers[selectedIndex].Provider, nil
|
||||
}
|
||||
|
||||
// SupportsBYOModelFetching returns true if the provider supports fetching models dynamically
|
||||
// from a remote API, or if it has a static list of predefined models.
|
||||
// This is used to determine whether to show a model list before prompting for manual entry.
|
||||
func SupportsBYOModelFetching(provider cline.ApiProvider) bool {
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return true
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return true
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return true
|
||||
}
|
||||
|
||||
return SupportsStaticModelList(provider)
|
||||
}
|
||||
|
||||
// GetBYOProviderPlaceholder returns a placeholder model ID for manual entry based on provider.
|
||||
func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "e.g., claude-sonnet-4-5-20250929"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "e.g., gpt-5-2025-08-07"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "e.g., openai/gpt-oss-120b"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "e.g., google/gemini-2.0-flash-exp:free"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "e.g., grok-code-fast-1"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "e.g., anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "e.g., gemini-2.5-pro"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "e.g., qwen3-coder:30b"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "e.g., gpt-oss-120b"
|
||||
default:
|
||||
return "Enter model ID"
|
||||
}
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns field configuration for API key input based on provider.
|
||||
type APIKeyFieldConfig struct {
|
||||
Title string
|
||||
EchoMode huh.EchoMode
|
||||
IsRequired bool
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns the configuration for the API key field based on provider.
|
||||
func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig {
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
return APIKeyFieldConfig{
|
||||
Title: "Base URL (optional, press Enter for default)",
|
||||
EchoMode: huh.EchoModeNormal,
|
||||
IsRequired: false,
|
||||
}
|
||||
}
|
||||
|
||||
return APIKeyFieldConfig{
|
||||
Title: "API Key",
|
||||
EchoMode: huh.EchoModePassword,
|
||||
IsRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama).
|
||||
// For OpenAI Native provider, also prompts for an optional base URL.
|
||||
func PromptForAPIKey(provider cline.ApiProvider) (string, error) {
|
||||
var apiKey string
|
||||
config := GetBYOAPIKeyFieldConfig(provider)
|
||||
|
||||
apiKeyField := huh.NewInput().
|
||||
Title(config.Title).
|
||||
EchoMode(config.EchoMode).
|
||||
Value(&apiKey)
|
||||
|
||||
if config.IsRequired {
|
||||
apiKeyField = apiKeyField.Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("API key cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(apiKeyField))
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// For OpenAI Native provider, also prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI_NATIVE {
|
||||
var baseURL string
|
||||
baseURLForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Base URL (optional, for OpenAI-compatible providers)").
|
||||
Placeholder("e.g., https://api.example.com/v1").
|
||||
Value(&baseURL).
|
||||
Description("Press Enter to skip if using standard OpenAI API"),
|
||||
),
|
||||
)
|
||||
|
||||
if err := baseURLForm.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get base URL: %w", err)
|
||||
}
|
||||
|
||||
// TODO - connect baseURL
|
||||
_ = baseURL
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderDisplay represents a configured provider for display purposes
|
||||
type ProviderDisplay struct {
|
||||
Mode string // "Plan" or "Act"
|
||||
Provider cline.ApiProvider // Provider enum
|
||||
ModelID string // Model identifier
|
||||
HasAPIKey bool // Whether an API key is configured (never show actual key)
|
||||
BaseURL string // Base URL for providers like Ollama (can be shown publicly)
|
||||
}
|
||||
|
||||
// ProviderListResult holds the parsed provider configuration from state
|
||||
type ProviderListResult struct {
|
||||
PlanProvider *ProviderDisplay
|
||||
ActProvider *ProviderDisplay
|
||||
apiConfig map[string]interface{} // Store the raw apiConfig for scanning all providers
|
||||
}
|
||||
|
||||
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
|
||||
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
|
||||
}
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Retrieved state, parsing JSON (length: %d)\n", len(stateJSON))
|
||||
}
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Parsed state data with %d keys\n", len(stateData))
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] No apiConfiguration found in state")
|
||||
}
|
||||
return &ProviderListResult{
|
||||
apiConfig: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Found apiConfiguration with %d keys\n", len(apiConfig))
|
||||
}
|
||||
|
||||
// Extract plan mode configuration
|
||||
planProvider := extractProviderFromState(apiConfig, "plan")
|
||||
if global.Config.Verbose && planProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode: provider=%v, model=%s\n", planProvider.Provider, planProvider.ModelID)
|
||||
}
|
||||
|
||||
// Extract act mode configuration
|
||||
actProvider := extractProviderFromState(apiConfig, "act")
|
||||
if global.Config.Verbose && actProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode: provider=%v, model=%s\n", actProvider.Provider, actProvider.ModelID)
|
||||
}
|
||||
|
||||
return &ProviderListResult{
|
||||
PlanProvider: planProvider,
|
||||
ActProvider: actProvider,
|
||||
apiConfig: apiConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllReadyProviders returns all providers that have both a model and API key configured
|
||||
func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
if r.apiConfig == nil {
|
||||
return []*ProviderDisplay{}
|
||||
}
|
||||
|
||||
var readyProviders []*ProviderDisplay
|
||||
seenProviders := make(map[cline.ApiProvider]bool)
|
||||
|
||||
// Check all possible providers
|
||||
allProviders := []cline.ApiProvider{
|
||||
cline.ApiProvider_CLINE,
|
||||
cline.ApiProvider_ANTHROPIC,
|
||||
cline.ApiProvider_OPENAI,
|
||||
cline.ApiProvider_OPENAI_NATIVE,
|
||||
cline.ApiProvider_OPENROUTER,
|
||||
cline.ApiProvider_XAI,
|
||||
cline.ApiProvider_BEDROCK,
|
||||
cline.ApiProvider_GEMINI,
|
||||
cline.ApiProvider_OLLAMA,
|
||||
cline.ApiProvider_CEREBRAS,
|
||||
}
|
||||
|
||||
// Check each provider to see if it's ready to use
|
||||
// We use "plan" mode to check, since both plan and act should have the same providers configured
|
||||
for _, provider := range allProviders {
|
||||
// Skip if we've already seen this provider
|
||||
if seenProviders[provider] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has an API key
|
||||
hasAPIKey := checkAPIKeyExists(r.apiConfig, provider)
|
||||
if !hasAPIKey {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has a model configured
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get base URL for Ollama
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := r.apiConfig["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// This provider is ready to use
|
||||
readyProviders = append(readyProviders, &ProviderDisplay{
|
||||
Mode: "Ready",
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
seenProviders[provider] = true
|
||||
}
|
||||
|
||||
return readyProviders
|
||||
}
|
||||
|
||||
// extractProviderFromState extracts provider configuration for specific plan/act mode
|
||||
func extractProviderFromState(stateData map[string]interface{}, mode string) *ProviderDisplay {
|
||||
// Build key names based on mode
|
||||
providerKey := mode + "ModeApiProvider"
|
||||
|
||||
// Extract provider string from state
|
||||
providerStr, ok := stateData[providerKey].(string)
|
||||
if !ok || providerStr == "" {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] No provider configured for %s mode\n", mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Map provider string to enum
|
||||
provider, ok := mapProviderStringToEnum(providerStr)
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Unknown provider type: %s\n", providerStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get provider-specific model ID
|
||||
modelID := getProviderSpecificModelID(stateData, mode, provider)
|
||||
|
||||
// Check if API key exists
|
||||
hasAPIKey := checkAPIKeyExists(stateData, provider)
|
||||
|
||||
// Get base URL for Ollama (can be shown publicly)
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := stateData["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
return &ProviderDisplay{
|
||||
Mode: capitalizeMode(mode),
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
BaseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// mapProviderStringToEnum converts provider string from state to ApiProvider enum
|
||||
// Returns (provider, ok) where ok is false if the provider is unknown
|
||||
func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
|
||||
// Map string values to enum values
|
||||
switch providerStr {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, true
|
||||
case "openai":
|
||||
return cline.ApiProvider_OPENAI, true
|
||||
case "openai-native":
|
||||
return cline.ApiProvider_OPENAI_NATIVE, true
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, true
|
||||
case "xai":
|
||||
return cline.ApiProvider_XAI, true
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, true
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, true
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, true
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, true
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, true
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false
|
||||
}
|
||||
}
|
||||
|
||||
// GetProviderIDForEnum converts a provider enum to the provider ID string
|
||||
// This is the inverse of mapProviderStringToEnum and is used for provider definitions
|
||||
func GetProviderIDForEnum(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "openai"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "openai-native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "openrouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "xai"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "cline"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// getProviderSpecificModelID gets the provider-specific model ID field from state
|
||||
func getProviderSpecificModelID(stateData map[string]interface{}, mode string, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, mode)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Error getting model ID field name: %v\n", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Looking for model ID in key: %s\n", modelKey)
|
||||
}
|
||||
|
||||
// Extract model ID from state
|
||||
modelID, _ := stateData[modelKey].(string)
|
||||
return modelID
|
||||
}
|
||||
|
||||
// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// Get field mapping from centralized function
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
keyField := fields.APIKeyField
|
||||
|
||||
// Check if the key exists and is not empty
|
||||
if value, ok := stateData[keyField]; ok {
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// capitalizeMode capitalizes the mode string for display
|
||||
func capitalizeMode(mode string) string {
|
||||
if len(mode) == 0 {
|
||||
return mode
|
||||
}
|
||||
return strings.ToUpper(mode[:1]) + mode[1:]
|
||||
}
|
||||
|
||||
// getProviderDisplayName returns a user-friendly name for the provider
|
||||
func getProviderDisplayName(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "Anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "OpenAI"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "OpenAI Native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "OpenRouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "X AI (Grok)"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "AWS Bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "Google Gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "Ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "Cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "Cline (Official)"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatProviderList formats the complete provider list for console display
|
||||
// This now shows ALL providers that have both a model and API key configured
|
||||
func FormatProviderList(result *ProviderListResult) string {
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("\n=== Configured API Providers ===\n\n")
|
||||
|
||||
// Get the currently active provider
|
||||
var activeProvider cline.ApiProvider
|
||||
var activeProviderSet bool
|
||||
if result.ActProvider != nil {
|
||||
activeProvider = result.ActProvider.Provider
|
||||
activeProviderSet = true
|
||||
}
|
||||
|
||||
// Get all ready-to-use providers (those with both API key and model configured)
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
if len(readyProviders) == 0 {
|
||||
output.WriteString(" No providers ready to use.\n")
|
||||
output.WriteString(" A provider is ready when it has both a model and API key configured.\n")
|
||||
output.WriteString(" Use 'Configure a new provider' to configure one.\n\n")
|
||||
} else {
|
||||
//output.WriteString(fmt.Sprintf(" %d provider(s) ready to use:\n\n", len(readyProviders)))
|
||||
|
||||
for _, display := range readyProviders {
|
||||
// Check if this is the active provider
|
||||
isActive := activeProviderSet && display.Provider == activeProvider
|
||||
|
||||
if isActive {
|
||||
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", getProviderDisplayName(display.Provider)))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf(" • %s\n", getProviderDisplayName(display.Provider)))
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID))
|
||||
|
||||
// Show status based on provider type
|
||||
if display.Provider == cline.ApiProvider_OLLAMA {
|
||||
if display.BaseURL != "" {
|
||||
output.WriteString(fmt.Sprintf(" Base URL: %s\n", display.BaseURL))
|
||||
} else {
|
||||
output.WriteString(" Base URL: (default)\n")
|
||||
}
|
||||
} else if display.Provider == cline.ApiProvider_CLINE {
|
||||
output.WriteString(" Status: Authenticated\n")
|
||||
} else {
|
||||
output.WriteString(" API Key: Configured\n")
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteString("================================\n")
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// DetectAllConfiguredProviders scans the state to find all providers that have API keys configured.
|
||||
// This allows switching between multiple providers even when only one is currently active.
|
||||
func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([]cline.ApiProvider, error) {
|
||||
verboseLog("[DEBUG] Detecting all configured providers...")
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
verboseLog("[DEBUG] No apiConfiguration found in state")
|
||||
verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData))
|
||||
return []cline.ApiProvider{}, nil
|
||||
}
|
||||
|
||||
verboseLog("[DEBUG] apiConfiguration keys: %v", getMapKeys(apiConfig))
|
||||
|
||||
var configuredProviders []cline.ApiProvider
|
||||
|
||||
// Check for Cline provider (uses authentication instead of API key)
|
||||
if IsAuthenticated(ctx) {
|
||||
configuredProviders = append(configuredProviders, cline.ApiProvider_CLINE)
|
||||
verboseLog("[DEBUG] Cline provider is authenticated")
|
||||
}
|
||||
|
||||
// Check each BYO provider for API key presence
|
||||
providersToCheck := []struct {
|
||||
provider cline.ApiProvider
|
||||
keyField string
|
||||
}{
|
||||
{cline.ApiProvider_ANTHROPIC, "apiKey"},
|
||||
{cline.ApiProvider_OPENAI, "openAiApiKey"},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"},
|
||||
{cline.ApiProvider_OPENROUTER, "openRouterApiKey"},
|
||||
{cline.ApiProvider_XAI, "xaiApiKey"},
|
||||
{cline.ApiProvider_BEDROCK, "awsAccessKey"},
|
||||
{cline.ApiProvider_GEMINI, "geminiApiKey"},
|
||||
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", getProviderDisplayName(providerCheck.provider), providerCheck.keyField)
|
||||
if value, ok := apiConfig[providerCheck.keyField]; ok {
|
||||
verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "")
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
configuredProviders = append(configuredProviders, providerCheck.provider)
|
||||
verboseLog("[DEBUG] ✓ Provider %s is configured", getProviderDisplayName(providerCheck.provider))
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", providerCheck.keyField)
|
||||
}
|
||||
}
|
||||
|
||||
verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders))
|
||||
for _, p := range configuredProviders {
|
||||
verboseLog("[DEBUG] - %s", getProviderDisplayName(p))
|
||||
}
|
||||
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// getMapKeys returns the keys of a map for debugging
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// updateApiConfigurationPartial is a helper that calls the gRPC method with optional verbose logging.
|
||||
// This replaces the Manager.UpdateApiConfigurationPartial method to keep auth-specific code in the auth package.
|
||||
func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, request *cline.UpdateApiConfigurationPartialRequest) error {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Updating API configuration (partial)")
|
||||
if request.UpdateMask != nil && len(request.UpdateMask.Paths) > 0 {
|
||||
fmt.Printf("[DEBUG] Field mask paths: %v\n", request.UpdateMask.Paths)
|
||||
}
|
||||
if request.ApiConfiguration != nil {
|
||||
apiConfig := request.ApiConfiguration
|
||||
if apiConfig.PlanModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode provider: %s\n", *apiConfig.PlanModeApiProvider)
|
||||
}
|
||||
if apiConfig.ActModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode provider: %s\n", *apiConfig.ActModeApiProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the Models service to update API configuration
|
||||
_, err := manager.GetClient().Models.UpdateApiConfigurationPartial(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update API configuration (partial): %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] API configuration updated successfully (partial)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderFields defines all the field names associated with a specific provider
|
||||
type ProviderFields struct {
|
||||
APIKeyField string // API key field name (e.g., "apiKey", "openAiApiKey")
|
||||
PlanModeModelIDField string // Plan mode model ID field (e.g., "planModeApiModelId")
|
||||
ActModeModelIDField string // Act mode model ID field (e.g., "actModeApiModelId")
|
||||
PlanModeModelInfoField string // Plan mode model info field (optional, empty if not applicable)
|
||||
ActModeModelInfoField string // Act mode model info field (optional, empty if not applicable)
|
||||
// Provider-specific additional model ID fields
|
||||
PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId"
|
||||
ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId"
|
||||
}
|
||||
|
||||
// GetProviderFields returns the field mapping for a given provider
|
||||
func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return ProviderFields{
|
||||
APIKeyField: "apiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenAiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openRouterApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_XAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "xaiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return ProviderFields{
|
||||
APIKeyField: "awsAccessKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeAwsBedrockCustomModelBaseId",
|
||||
ActModeProviderSpecificModelIDField: "actModeAwsBedrockCustomModelBaseId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "geminiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiNativeApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ollamaBaseUrl",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOllamaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOllamaModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return ProviderFields{
|
||||
APIKeyField: "cerebrasApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CLINE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "clineApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderUpdatesPartial defines optional fields for partial provider updates
|
||||
// Uses pointers to distinguish between "not provided" and "set to empty"
|
||||
type ProviderUpdatesPartial struct {
|
||||
ModelID *string // New model ID (optional)
|
||||
APIKey *string // New API key (optional)
|
||||
ModelInfo interface{} // New model info (optional, provider-specific)
|
||||
}
|
||||
|
||||
// GetModelIDFieldName returns the appropriate model ID field name for a provider and mode.
|
||||
// This helper centralizes the logic for determining whether to use provider-specific
|
||||
// or generic model ID fields.
|
||||
func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error) {
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if mode == "plan" {
|
||||
// Use provider-specific field if available, otherwise use generic field
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
return fields.PlanModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.PlanModeModelIDField, nil
|
||||
}
|
||||
|
||||
// Act mode
|
||||
if fields.ActModeProviderSpecificModelIDField != "" {
|
||||
return fields.ActModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.ActModeModelIDField, nil
|
||||
}
|
||||
|
||||
// buildProviderFieldMask builds a list of camelCase field paths for the field mask.
|
||||
// When includeProviderEnums is true, the provider enum fields are included (for setting active provider).
|
||||
// When false, only the data fields are included (for configuring without activating).
|
||||
func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeProviderEnums bool) []string {
|
||||
var fieldPaths []string
|
||||
|
||||
// Include provider enums if requested (used when setting active provider)
|
||||
if includeProviderEnums {
|
||||
fieldPaths = append(fieldPaths, "planModeApiProvider", "actModeApiProvider")
|
||||
}
|
||||
|
||||
// Add API key field if requested
|
||||
if includeAPIKey {
|
||||
fieldPaths = append(fieldPaths, fields.APIKeyField)
|
||||
// Special case: Bedrock also needs secret key
|
||||
if fields.APIKeyField == "awsAccessKey" {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
}
|
||||
|
||||
// Add model ID fields if requested
|
||||
if includeModelID {
|
||||
// Only include provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
// Provider has specific fields - use ONLY those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeProviderSpecificModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeProviderSpecificModelIDField)
|
||||
} else {
|
||||
// Provider uses generic fields - update those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelIDField)
|
||||
}
|
||||
}
|
||||
|
||||
// Add model info fields if requested and applicable
|
||||
if includeModelInfo && fields.PlanModeModelInfoField != "" {
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelInfoField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelInfoField)
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
|
||||
// setAPIKeyField sets the appropriate API key field in the config based on the field name
|
||||
func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "apiKey":
|
||||
apiConfig.ApiKey = value
|
||||
case "openAiApiKey":
|
||||
apiConfig.OpenAiApiKey = value
|
||||
case "openAiNativeApiKey":
|
||||
apiConfig.OpenAiNativeApiKey = value
|
||||
case "openRouterApiKey":
|
||||
apiConfig.OpenRouterApiKey = value
|
||||
case "xaiApiKey":
|
||||
apiConfig.XaiApiKey = value
|
||||
case "awsAccessKey":
|
||||
apiConfig.AwsAccessKey = value
|
||||
case "geminiApiKey":
|
||||
apiConfig.GeminiApiKey = value
|
||||
case "ollamaBaseUrl":
|
||||
apiConfig.OllamaBaseUrl = value
|
||||
case "cerebrasApiKey":
|
||||
apiConfig.CerebrasApiKey = value
|
||||
case "clineApiKey":
|
||||
apiConfig.ClineApiKey = value
|
||||
}
|
||||
}
|
||||
|
||||
// setProviderSpecificModelID sets the appropriate provider-specific model ID fields when possible
|
||||
func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "planModeOpenAiModelId":
|
||||
apiConfig.PlanModeOpenAiModelId = value
|
||||
apiConfig.ActModeOpenAiModelId = value
|
||||
case "planModeOpenRouterModelId":
|
||||
apiConfig.PlanModeOpenRouterModelId = value
|
||||
apiConfig.ActModeOpenRouterModelId = value
|
||||
case "planModeOllamaModelId":
|
||||
apiConfig.PlanModeOllamaModelId = value
|
||||
apiConfig.ActModeOllamaModelId = value
|
||||
case "planModeAwsBedrockCustomModelBaseId":
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = value
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = value
|
||||
}
|
||||
}
|
||||
|
||||
// AddProviderPartial configures a new provider with all necessary fields using partial updates.
|
||||
func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, modelInfo interface{}) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the relevant provider fields set
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set API key field
|
||||
if apiKey != "" || fields.APIKeyField != "ollamaBaseUrl" {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, proto.String(apiKey))
|
||||
}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
|
||||
// Set provider-specific model ID fields if applicable
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, proto.String(modelID))
|
||||
}
|
||||
|
||||
// Set model info if applicable and provided
|
||||
if fields.PlanModeModelInfoField != "" && modelInfo != nil {
|
||||
if openRouterInfo, ok := modelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask including all fields we're setting (without provider enums)
|
||||
includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil
|
||||
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, false)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProviderPartial updates specific fields for an existing provider using partial updates.
|
||||
// If setAsActive is true, this will also set the provider as the active provider for both Plan and Act modes.
|
||||
func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, updates ProviderUpdatesPartial, setAsActive bool) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the fields being updated
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set provider enum for BOTH Plan and Act modes if setAsActive is true
|
||||
if setAsActive {
|
||||
apiConfig.PlanModeApiProvider = &provider
|
||||
apiConfig.ActModeApiProvider = &provider
|
||||
}
|
||||
|
||||
// Track what we're updating for field mask
|
||||
includeAPIKey := updates.APIKey != nil
|
||||
includeModelID := updates.ModelID != nil
|
||||
includeModelInfo := updates.ModelInfo != nil && fields.PlanModeModelInfoField != ""
|
||||
|
||||
// Update API key if provided
|
||||
if updates.APIKey != nil {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, updates.APIKey)
|
||||
}
|
||||
|
||||
// Update model ID if provided
|
||||
if updates.ModelID != nil {
|
||||
// Only set provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, updates.ModelID)
|
||||
} else {
|
||||
// Provider uses generic fields - set those
|
||||
apiConfig.PlanModeApiModelId = updates.ModelID
|
||||
apiConfig.ActModeApiModelId = updates.ModelID
|
||||
}
|
||||
}
|
||||
|
||||
// Update model info if provided
|
||||
if updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" {
|
||||
if openRouterInfo, ok := updates.ModelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask for only the fields being updated
|
||||
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, setAsActive)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProviderPartial removes a provider by clearing its API key using partial updates
|
||||
func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build an EMPTY ModelsApiConfiguration (or one with empty API key field)
|
||||
// Fields in the mask without values will be cleared
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Build field mask with only the API key field(s)
|
||||
// For Bedrock, include both access key and secret key
|
||||
fieldPaths := []string{fields.APIKeyField}
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update (clearing API key by including in mask without value)
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BedrockOptionalFields holds optional configuration fields for AWS Bedrock
|
||||
type BedrockOptionalFields struct {
|
||||
SessionToken *string // Optional: AWS session token for temporary credentials
|
||||
Region *string // Optional: AWS region
|
||||
UseCrossRegionInference *bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference *bool // Optional: Use global inference endpoint
|
||||
UsePromptCache *bool // Optional: Enable prompt caching
|
||||
Authentication *string // Optional: Authentication method
|
||||
UseProfile *bool // Optional: Use AWS profile
|
||||
Profile *string // Optional: AWS profile name
|
||||
Endpoint *string // Optional: Custom endpoint URL
|
||||
}
|
||||
|
||||
// setBedrockOptionalFields sets optional Bedrock-specific fields in the API configuration
|
||||
func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *BedrockOptionalFields) {
|
||||
if fields == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
apiConfig.AwsSessionToken = fields.SessionToken
|
||||
}
|
||||
if fields.Region != nil {
|
||||
apiConfig.AwsRegion = fields.Region
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
apiConfig.AwsUseCrossRegionInference = fields.UseCrossRegionInference
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
apiConfig.AwsUseGlobalInference = fields.UseGlobalInference
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
apiConfig.AwsBedrockUsePromptCache = fields.UsePromptCache
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
apiConfig.AwsAuthentication = fields.Authentication
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
apiConfig.AwsUseProfile = fields.UseProfile
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
apiConfig.AwsProfile = fields.Profile
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
apiConfig.AwsBedrockEndpoint = fields.Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// buildBedrockOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
|
||||
func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldPaths []string
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
fieldPaths = append(fieldPaths, "awsSessionToken")
|
||||
}
|
||||
if fields.Region != nil {
|
||||
fieldPaths = append(fieldPaths, "awsRegion")
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseCrossRegionInference")
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseGlobalInference")
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockUsePromptCache")
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
fieldPaths = append(fieldPaths, "awsAuthentication")
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseProfile")
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsProfile")
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockEndpoint")
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderWizard handles the interactive provider configuration process
|
||||
type ProviderWizard struct {
|
||||
ctx context.Context
|
||||
manager *task.Manager
|
||||
}
|
||||
|
||||
// NewProviderWizard prepares a new provider configuration wizard
|
||||
func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) {
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure Cline Core instance: %w", err)
|
||||
}
|
||||
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderWizard{
|
||||
ctx: ctx,
|
||||
manager: manager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// showMainMenu displays the main provider configuration menu
|
||||
func (pw *ProviderWizard) showMainMenu() (string, error) {
|
||||
var action string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Configure a new provider", "add"),
|
||||
huh.NewOption("Change model for API provider", "change-model"),
|
||||
huh.NewOption("Remove a provider", "remove"),
|
||||
huh.NewOption("List configured providers", "list"),
|
||||
huh.NewOption("Return to main auth menu", "back"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// Run runs the provider configuration wizard
|
||||
func (pw *ProviderWizard) Run() error {
|
||||
|
||||
for {
|
||||
action, err := pw.showMainMenu()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if err := pw.handleAddProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "change-model":
|
||||
if err := pw.handleChangeModel(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "remove":
|
||||
if err := pw.handleRemoveProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "list":
|
||||
if err := pw.handleListProviders(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "back":
|
||||
// Return to main auth menu
|
||||
return HandleAuthMenuNoArgs(pw.ctx)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// "Add a new provider" > handleAddProvider
|
||||
func (pw *ProviderWizard) handleAddProvider() error {
|
||||
// Step 1: Select provider
|
||||
provider, err := SelectBYOProvider()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("provider selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Special handling for Bedrock provider
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
return pw.handleAddBedrockProvider()
|
||||
}
|
||||
|
||||
// Step 3: Get API key first (for non-Bedrock providers)
|
||||
apiKey, err := PromptForAPIKey(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Try to fetch models and let user select (with fallback to manual entry for providers that don't support fetch)
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Apply configuration using AddProviderPartial
|
||||
if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAddBedrockProvider handles the special case of adding Bedrock provider with its multi-field form
|
||||
func (pw *ProviderWizard) handleAddBedrockProvider() error {
|
||||
// Step 1: Get Bedrock configuration (all credentials and optional fields)
|
||||
config, err := PromptForBedrockConfig(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user declined profile authentication") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Select model
|
||||
modelID, modelInfo, err := pw.selectModel(cline.ApiProvider_BEDROCK, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Apply Bedrock configuration
|
||||
if err := ApplyBedrockConfig(pw.ctx, pw.manager, config, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
|
||||
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Bedrock provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleListProviders retrieves and displays configured providers
|
||||
func (pw *ProviderWizard) handleListProviders() error {
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
output := FormatProviderList(result)
|
||||
fmt.Println(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectModel attempts to fetch available models and let user select, or falls back to manual entry
|
||||
func (pw *ProviderWizard) selectModel(provider cline.ApiProvider, apiKey string) (string, interface{}, error) {
|
||||
// For providers that support model fetching, try to fetch and display models
|
||||
canFetchModels := pw.supportsModelFetching(provider)
|
||||
|
||||
if canFetchModels {
|
||||
fmt.Println("Fetching available models...")
|
||||
models, modelInfoMap, err := pw.fetchModelsForProvider(provider, apiKey)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("\n⚠ Unable to fetch model list from the provider. Please enter the model ID manually instead.")
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf(" Error details: %v\n", err)
|
||||
}
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
fmt.Println("\n⚠ No models found from the provider. Please enter the model ID manually instead.")
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Let user select from available models (includes manual entry option)
|
||||
modelID, err := pw.selectFromAvailableModels(models)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if user chose manual entry
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
if modelID == manualEntryKey {
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Get the model info for the selected model
|
||||
var modelInfo interface{}
|
||||
if modelInfoMap != nil {
|
||||
modelInfo = modelInfoMap[modelID]
|
||||
}
|
||||
|
||||
return modelID, modelInfo, nil
|
||||
}
|
||||
|
||||
// For providers without model fetching support, use manual entry
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// supportsModelFetching returns true if the provider supports fetching models
|
||||
func (pw *ProviderWizard) supportsModelFetching(provider cline.ApiProvider) bool {
|
||||
return SupportsBYOModelFetching(provider)
|
||||
}
|
||||
|
||||
// fetchModelsForProvider fetches models for a given provider
|
||||
// Supports both dynamic API fetching (OpenRouter, OpenAI, Ollama) and static model lists (Anthropic, Bedrock, Gemini, X AI)
|
||||
func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, apiKey string) ([]string, map[string]interface{}, error) {
|
||||
// Try dynamic/remote model fetching first
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
models, err := FetchOpenRouterModels(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
interfaceMap := ConvertOpenRouterModelsToInterface(models)
|
||||
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
// For OpenAI, we need to pass the base URL and API key
|
||||
baseURL := "https://api.openai.com/v1" // Default OpenAI API base URL
|
||||
modelIDs, err := FetchOpenAiModels(pw.ctx, pw.manager, baseURL, apiKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// OpenAI returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
// For Ollama, apiKey actually contains the base URL (or empty for default)
|
||||
baseURL := apiKey // The "API key" field for Ollama is actually the base URL
|
||||
modelIDs, err := FetchOllamaModels(pw.ctx, pw.manager, baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Ollama returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
}
|
||||
|
||||
// Fall back to static models for providers that don't support dynamic fetching
|
||||
if SupportsStaticModelList(provider) {
|
||||
modelIDs, _, err := FetchStaticModels(provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Static models don't have detailed info maps for now, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("model fetching not supported for provider: %v", provider)
|
||||
}
|
||||
|
||||
// selectFromAvailableModels displays available models and lets user select one.
|
||||
// Includes an option to enter a model ID manually in case the desired model isn't listed.
|
||||
func (pw *ProviderWizard) selectFromAvailableModels(models []string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available")
|
||||
}
|
||||
|
||||
// Add a special "manual entry" option at the end
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
|
||||
// Use model ID as the value (not index)
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models)+1)
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
// Add manual entry option at the end
|
||||
options[len(models)] = huh.NewOption("Enter model ID manually...", manualEntryKey)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select a model").
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
// If user selected manual entry, return special key to trigger manual input
|
||||
if selectedModel == manualEntryKey {
|
||||
return manualEntryKey, nil
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// manualModelEntry prompts user to manually enter a model ID.
|
||||
// Returns the model ID and an error. The modelInfo is always nil for manual entry.
|
||||
func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string, interface{}, error) {
|
||||
var modelID string
|
||||
modelPlaceholder := GetBYOProviderPlaceholder(provider)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Model ID").
|
||||
Placeholder(modelPlaceholder).
|
||||
Value(&modelID).
|
||||
Validate(func(s string) error {
|
||||
// Trim whitespace and validate
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("model ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get model ID: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from the final value
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
|
||||
// modelInfo is always nil for manual entry
|
||||
return modelID, nil, nil
|
||||
}
|
||||
|
||||
// handleChangeModel allows changing the model for any configured provider
|
||||
func (pw *ProviderWizard) handleChangeModel() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all configured providers with models
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (it has its own model changer in the main menu)
|
||||
var configurableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
configurableProviders = append(configurableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are any configurable providers
|
||||
if len(configurableProviders) == 0 {
|
||||
fmt.Println("\nNo configurable providers found.")
|
||||
fmt.Println("Note: Cline provider has its own model selection in the main menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Let user select which provider to change the model for
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(configurableProviders)+1)
|
||||
for i, providerDisplay := range configurableProviders {
|
||||
displayName := fmt.Sprintf("%s (current: %s)",
|
||||
getProviderDisplayName(providerDisplay.Provider),
|
||||
providerDisplay.ModelID)
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
options[len(configurableProviders)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to change model for").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedProvider := configurableProviders[selectedIndex]
|
||||
provider := selectedProvider.Provider
|
||||
|
||||
fmt.Printf("\nChanging model for %s\n", getProviderDisplayName(provider))
|
||||
fmt.Printf("Current model: %s\n\n", selectedProvider.ModelID)
|
||||
|
||||
// Step 5: Retrieve API key if needed for model fetching
|
||||
var apiKey string
|
||||
if pw.supportsModelFetching(provider) {
|
||||
// For providers that support fetching, we need to retrieve the API key from state
|
||||
state, err := pw.manager.GetClient().State.GetLatestState(pw.ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
apiKey = getProviderAPIKeyFromState(apiConfig, provider)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key found for provider %s", getProviderDisplayName(provider))
|
||||
}
|
||||
}
|
||||
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Apply the model change (for both Plan and Act modes)
|
||||
if err := pw.applyModelChange(provider, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to apply model change: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Model changed successfully to: %s\n", modelID)
|
||||
fmt.Println(" (Applied to both Plan and Act modes)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyModelChange applies a model change for both Plan and Act modes using UpdateProviderPartial
|
||||
func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID string, modelInfo interface{}) error {
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, false)
|
||||
}
|
||||
|
||||
// SwitchToBYOProvider switches to a BYO provider that's already configured.
|
||||
// It retrieves the existing model configuration and sets it as the active provider for both Plan and Act modes.
|
||||
func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get the current state to retrieve the model ID and model info for this provider
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
// Get the model ID for the selected provider
|
||||
modelID := getProviderModelIDFromState(apiConfig, provider)
|
||||
if modelID == "" {
|
||||
return fmt.Errorf("no model configured for provider %s", getProviderDisplayName(provider))
|
||||
}
|
||||
|
||||
// Get model info if available (for OpenRouter/Cline)
|
||||
var modelInfo interface{}
|
||||
if provider == cline.ApiProvider_OPENROUTER || provider == cline.ApiProvider_CLINE {
|
||||
if modelInfoData, ok := apiConfig["planModeOpenRouterModelInfo"].(map[string]interface{}); ok {
|
||||
modelInfo = convertMapToOpenRouterModelInfo(modelInfoData)
|
||||
}
|
||||
}
|
||||
|
||||
// Use UpdateProviderPartial to switch to this provider
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
if err := UpdateProviderPartial(ctx, manager, provider, updates, true); err != nil {
|
||||
return fmt.Errorf("failed to switch provider: %w", err)
|
||||
}
|
||||
|
||||
verboseLog("✓ Switched to %s\n", getProviderDisplayName(provider))
|
||||
verboseLog(" Using model: %s\n", modelID)
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// getProviderModelIDFromState retrieves the model ID for a specific provider from state
|
||||
func getProviderModelIDFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, "plan")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if modelID, ok := stateData[modelKey].(string); ok {
|
||||
return modelID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if apiKey, ok := stateData[fields.APIKeyField].(string); ok {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// convertMapToOpenRouterModelInfo converts a map to OpenRouterModelInfo
|
||||
func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRouterModelInfo {
|
||||
info := &cline.OpenRouterModelInfo{}
|
||||
|
||||
if val, ok := data["description"].(string); ok {
|
||||
info.Description = &val
|
||||
}
|
||||
if val, ok := data["contextWindow"].(float64); ok {
|
||||
contextWindow := int64(val)
|
||||
info.ContextWindow = &contextWindow
|
||||
}
|
||||
if val, ok := data["maxTokens"].(float64); ok {
|
||||
maxTokens := int64(val)
|
||||
info.MaxTokens = &maxTokens
|
||||
}
|
||||
if val, ok := data["inputPrice"].(float64); ok {
|
||||
info.InputPrice = &val
|
||||
}
|
||||
if val, ok := data["outputPrice"].(float64); ok {
|
||||
info.OutputPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheWritesPrice"].(float64); ok {
|
||||
info.CacheWritesPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheReadsPrice"].(float64); ok {
|
||||
info.CacheReadsPrice = &val
|
||||
}
|
||||
if val, ok := data["supportsImages"].(bool); ok {
|
||||
info.SupportsImages = &val
|
||||
}
|
||||
if val, ok := data["supportsPromptCache"].(bool); ok {
|
||||
info.SupportsPromptCache = val
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// handleRemoveProvider allows removing a configured provider by clearing its API key
|
||||
func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all ready providers
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (uses account auth, not API keys)
|
||||
var removableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
removableProviders = append(removableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are providers to remove
|
||||
if len(removableProviders) == 0 {
|
||||
fmt.Println("\nNo providers available to remove.")
|
||||
fmt.Println("Note: Cline provider cannot be removed via this menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Display selection menu
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(removableProviders))
|
||||
for i, provider := range removableProviders {
|
||||
// Mark active provider
|
||||
displayName := getProviderDisplayName(provider.Provider)
|
||||
if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider {
|
||||
displayName += " (ACTIVE)"
|
||||
}
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to remove").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
selectedProvider := removableProviders[selectedIndex]
|
||||
|
||||
// Step 5: Check if trying to remove the active provider
|
||||
if result.ActProvider != nil && selectedProvider.Provider == result.ActProvider.Provider {
|
||||
fmt.Printf("\nCannot remove %s because it is currently active.\n", getProviderDisplayName(selectedProvider.Provider))
|
||||
fmt.Println("Please switch to a different provider first, then try again.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 6: Confirm removal
|
||||
var confirm bool
|
||||
confirmForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(fmt.Sprintf("Are you sure you want to remove %s?", getProviderDisplayName(selectedProvider.Provider))).
|
||||
Description("This will clear the API key but preserve the model configuration.").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := confirmForm.Run(); err != nil {
|
||||
return fmt.Errorf("failed to get confirmation: %w", err)
|
||||
}
|
||||
|
||||
if !confirm {
|
||||
fmt.Println("Removal cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 7: Clear the API key for the selected provider
|
||||
if err := pw.clearProviderAPIKey(selectedProvider.Provider); err != nil {
|
||||
return fmt.Errorf("failed to remove provider: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ %s removed successfully\n", getProviderDisplayName(selectedProvider.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearProviderAPIKey clears the API key field for a specific provider using RemoveProviderPartial
|
||||
func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error {
|
||||
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
|
||||
}
|
||||
|
||||
func setWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Optional features
|
||||
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"
|
||||
|
||||
// Legacy fields (no longer used in profile-only flow)
|
||||
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
|
||||
func PromptForBedrockConfig(ctx context.Context, manager *task.Manager) (*BedrockConfig, error) {
|
||||
config := &BedrockConfig{}
|
||||
|
||||
// First, ask if user wants to use AWS profile authentication
|
||||
var useProfile bool
|
||||
profileQuestion := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Do you want to use an AWS profile for authentication?").
|
||||
Description("AWS profiles are managed via 'aws configure'").
|
||||
Value(&useProfile).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := profileQuestion.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get authentication method: %w", err)
|
||||
}
|
||||
|
||||
// If user declines profile authentication, show message and return error
|
||||
if !useProfile {
|
||||
fmt.Println("\nAWS profile authentication is currently the only supported method in the CLI.")
|
||||
fmt.Println("Please configure an AWS profile using 'aws configure' and try again.")
|
||||
return nil, fmt.Errorf("user declined profile authentication")
|
||||
}
|
||||
|
||||
// User wants profile auth - collect profile configuration
|
||||
config.UseProfile = true
|
||||
config.Authentication = "profile"
|
||||
|
||||
// Collect profile name, region, and optional settings
|
||||
configForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("AWS Profile Name (optional, press Enter for default profile)").
|
||||
Value(&config.Profile).
|
||||
Description("Leave empty to use default AWS profile"),
|
||||
|
||||
huh.NewInput().
|
||||
Title("AWS Region (required, e.g., us-east-1)").
|
||||
Value(&config.Region).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("AWS Region is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Custom VPC Endpoint URL (optional)").
|
||||
Value(&config.Endpoint).
|
||||
Description("Press Enter to skip"),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Prompt Cache? ").
|
||||
Value(&config.UsePromptCache).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Cross-Region Inference? ").
|
||||
Value(&config.UseCrossRegionInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Use Global Inference Endpoint? ").
|
||||
Value(&config.UseGlobalInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := configForm.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields
|
||||
config.Profile = strings.TrimSpace(config.Profile)
|
||||
config.Region = strings.TrimSpace(config.Region)
|
||||
config.Endpoint = strings.TrimSpace(config.Endpoint)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ApplyBedrockConfig applies Bedrock configuration using partial updates (profile-only)
|
||||
func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *BedrockConfig, modelID string, modelInfo interface{}) error {
|
||||
// Build the API configuration with all Bedrock fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
|
||||
// Set profile authentication fields (always required)
|
||||
optionalFields := &BedrockOptionalFields{}
|
||||
optionalFields.Authentication = proto.String("profile")
|
||||
optionalFields.UseProfile = proto.Bool(true)
|
||||
optionalFields.Region = proto.String(config.Region)
|
||||
|
||||
// Set profile name (can be empty for default profile)
|
||||
if config.Profile != "" {
|
||||
optionalFields.Profile = proto.String(config.Profile)
|
||||
}
|
||||
|
||||
// Set optional fields if provided
|
||||
if config.Endpoint != "" {
|
||||
optionalFields.Endpoint = proto.String(config.Endpoint)
|
||||
}
|
||||
if config.UseCrossRegionInference {
|
||||
optionalFields.UseCrossRegionInference = proto.Bool(true)
|
||||
}
|
||||
if config.UseGlobalInference {
|
||||
optionalFields.UseGlobalInference = proto.Bool(true)
|
||||
}
|
||||
if config.UsePromptCache {
|
||||
optionalFields.UsePromptCache = proto.Bool(true)
|
||||
}
|
||||
|
||||
// Apply all fields to the config
|
||||
setBedrockOptionalFields(apiConfig, optionalFields)
|
||||
|
||||
// Build field mask including all fields we're setting (excluding access keys)
|
||||
fieldPaths := []string{
|
||||
"planModeApiModelId",
|
||||
"actModeApiModelId",
|
||||
"planModeAwsBedrockCustomModelBaseId",
|
||||
"actModeAwsBedrockCustomModelBaseId",
|
||||
}
|
||||
|
||||
// Add profile authentication field paths
|
||||
optionalPaths := buildBedrockOptionalFieldMask(optionalFields)
|
||||
fieldPaths = append(fieldPaths, optionalPaths...)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to apply Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package clerror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClineErrorType represents the category of error
|
||||
type ClineErrorType string
|
||||
|
||||
const (
|
||||
ErrorTypeAuth ClineErrorType = "auth"
|
||||
ErrorTypeNetwork ClineErrorType = "network"
|
||||
ErrorTypeRateLimit ClineErrorType = "rateLimit"
|
||||
ErrorTypeBalance ClineErrorType = "balance"
|
||||
ErrorTypeUnknown ClineErrorType = "unknown"
|
||||
)
|
||||
|
||||
// ClineError represents a parsed error from Cline API
|
||||
type ClineError struct {
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code interface{} `json:"code"` // Can be string or int
|
||||
ModelID string `json:"modelId"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
}
|
||||
|
||||
// GetCodeString returns the code as a string regardless of its type
|
||||
func (e *ClineError) GetCodeString() string {
|
||||
if e == nil || e.Code == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := e.Code.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit patterns from webview
|
||||
var rateLimitPatterns = []string{
|
||||
"status code 429",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"quota exceeded",
|
||||
"resource exhausted",
|
||||
}
|
||||
|
||||
// ParseClineError parses a JSON error string into a ClineError
|
||||
func ParseClineError(errorJSON string) (*ClineError, error) {
|
||||
if errorJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var err ClineError
|
||||
if parseErr := json.Unmarshal([]byte(errorJSON), &err); parseErr != nil {
|
||||
// If JSON parsing fails, create a simple error with the message
|
||||
return &ClineError{
|
||||
Message: errorJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &err, nil
|
||||
}
|
||||
|
||||
// GetErrorType determines the type of error based on code, status, and message
|
||||
func (e *ClineError) GetErrorType() ClineErrorType {
|
||||
if e == nil {
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
|
||||
// Check balance error first (most specific)
|
||||
codeStr := e.GetCodeString()
|
||||
if codeStr == "insufficient_credits" {
|
||||
return ErrorTypeBalance
|
||||
}
|
||||
|
||||
// Check auth errors
|
||||
if codeStr == "ERR_BAD_REQUEST" || e.Status == 401 {
|
||||
return ErrorTypeAuth
|
||||
}
|
||||
|
||||
// Check for auth message
|
||||
if strings.Contains(e.Message, "Authentication required") ||
|
||||
strings.Contains(e.Message, "Invalid API key") ||
|
||||
strings.Contains(e.Message, "Unauthorized") {
|
||||
return ErrorTypeAuth
|
||||
}
|
||||
|
||||
// Check rate limit patterns
|
||||
messageLower := strings.ToLower(e.Message)
|
||||
for _, pattern := range rateLimitPatterns {
|
||||
if strings.Contains(messageLower, pattern) {
|
||||
return ErrorTypeRateLimit
|
||||
}
|
||||
}
|
||||
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
|
||||
// IsBalanceError returns true if this is a balance/credits error
|
||||
func (e *ClineError) IsBalanceError() bool {
|
||||
return e.GetErrorType() == ErrorTypeBalance
|
||||
}
|
||||
|
||||
// IsAuthError returns true if this is an authentication error
|
||||
func (e *ClineError) IsAuthError() bool {
|
||||
return e.GetErrorType() == ErrorTypeAuth
|
||||
}
|
||||
|
||||
// IsRateLimitError returns true if this is a rate limit error
|
||||
func (e *ClineError) IsRateLimitError() bool {
|
||||
return e.GetErrorType() == ErrorTypeRateLimit
|
||||
}
|
||||
|
||||
// GetCurrentBalance returns the current balance if available
|
||||
func (e *ClineError) GetCurrentBalance() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if balance, ok := e.Details["current_balance"].(float64); ok {
|
||||
return &balance
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBuyCreditsURL returns the URL to buy credits if available
|
||||
func (e *ClineError) GetBuyCreditsURL() string {
|
||||
if e == nil || e.Details == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if url, ok := e.Details["buy_credits_url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTotalSpent returns the total spent amount if available
|
||||
func (e *ClineError) GetTotalSpent() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if spent, ok := e.Details["total_spent"].(float64); ok {
|
||||
return &spent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTotalPromotions returns the total promotions amount if available
|
||||
func (e *ClineError) GetTotalPromotions() *float64 {
|
||||
if e == nil || e.Details == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if promos, ok := e.Details["total_promotions"].(float64); ok {
|
||||
return &promos
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDetailMessage returns the detail message from error.details if available
|
||||
func (e *ClineError) GetDetailMessage() string {
|
||||
if e == nil || e.Details == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if msg, ok := e.Details["message"].(string); ok {
|
||||
return msg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/config"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var configManager *config.Manager
|
||||
|
||||
func ensureConfigManager(ctx context.Context, address string) error {
|
||||
if configManager == nil || (address != "" && configManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// 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)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, "")
|
||||
if err == nil {
|
||||
instanceAddress = configManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Manage Cline configuration",
|
||||
Long: `Set and manage global Cline configuration variables.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
cmd.AddCommand(newConfigGetCommand())
|
||||
cmd.AddCommand(setCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigGetCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Get a specific configuration value",
|
||||
Long: `Get the value of a specific configuration setting. Supports nested keys using dot notation (e.g., auto-approval-settings.actions.read-files).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
key := args[0]
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the setting
|
||||
return configManager.GetSetting(ctx, key)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all configuration settings",
|
||||
Long: `List all configuration settings from the Cline instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// List settings
|
||||
return configManager.ListSettings(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func setCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <key=value> [key=value...]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Set configuration variables",
|
||||
Long: `Set one or more global configuration variables using key=value format.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Parse using existing task parser
|
||||
settings, secrets, err := task.ParseTaskSettings(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update settings
|
||||
return configManager.UpdateSettings(ctx, settings, secrets)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
}
|
||||
|
||||
func NewManager(ctx context.Context, address string) (*Manager, error) {
|
||||
var c *client.ClineClient
|
||||
var err error
|
||||
|
||||
if address != "" {
|
||||
c, err = global.GetClientForAddress(ctx, address)
|
||||
} else {
|
||||
c, err = global.GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Get the actual address being used
|
||||
clientAddress := address
|
||||
if address == "" && global.Clients != nil {
|
||||
clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
client: c,
|
||||
clientAddress: clientAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateSettings(ctx context.Context, settings *cline.Settings, secrets *cline.Secrets) error {
|
||||
request := &cline.UpdateSettingsRequestCli{
|
||||
Metadata: &cline.Metadata{},
|
||||
Settings: settings,
|
||||
Secrets: secrets,
|
||||
}
|
||||
|
||||
// Call the updateSettingsCli RPC
|
||||
_, err := m.client.State.UpdateSettingsCli(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Settings updated successfully")
|
||||
fmt.Printf("Instance: %s\n", m.clientAddress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetState(ctx context.Context) (map[string]interface{}, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state: %w", err)
|
||||
}
|
||||
|
||||
return stateData, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListSettings(ctx context.Context) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subset of fields we will print the values for
|
||||
settingsFields := []string{
|
||||
"apiConfiguration",
|
||||
"telemetrySetting",
|
||||
"planActSeparateModelsSetting",
|
||||
"enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled",
|
||||
"shellIntegrationTimeout",
|
||||
"terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed",
|
||||
"mcpDisplayMode",
|
||||
"terminalOutputLineLimit",
|
||||
"mode",
|
||||
"preferredLanguage",
|
||||
"openaiReasoningEffort",
|
||||
"strictPlanModeEnabled",
|
||||
"focusChainSettings",
|
||||
"useAutoCondense",
|
||||
"customPrompt",
|
||||
"browserSettings",
|
||||
"defaultTerminalProfile",
|
||||
"yoloModeToggled",
|
||||
"dictationSettings",
|
||||
"autoCondenseThreshold",
|
||||
"autoApprovalSettings",
|
||||
}
|
||||
|
||||
// Render each field using the renderer
|
||||
for _, field := range settingsFields {
|
||||
if value, ok := stateData[field]; ok {
|
||||
if err := RenderField(field, value, true); err != nil {
|
||||
fmt.Printf("Error rendering %s: %v\n", field, err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetSetting(ctx context.Context, key string) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert kebab-case to camelCase path
|
||||
parts := kebabToCamelPath(key)
|
||||
rootField := parts[0]
|
||||
|
||||
// Get the value
|
||||
value, found := getNestedValue(stateData, parts)
|
||||
if !found {
|
||||
return fmt.Errorf("setting '%s' not found", key)
|
||||
}
|
||||
|
||||
// Render the value
|
||||
if len(parts) == 1 {
|
||||
// Top-level field: use RenderField for nice formatting
|
||||
return RenderField(rootField, value, false)
|
||||
} else {
|
||||
// Nested field: simple print
|
||||
fmt.Printf("%s: %s\n", key, formatValue(value, rootField, true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// kebabToCamelPath converts a kebab-case path to camelCase
|
||||
// e.g., "auto-approval-settings.actions.read-files" -> "autoApprovalSettings.actions.readFiles"
|
||||
func kebabToCamelPath(path string) []string {
|
||||
parts := strings.Split(path, ".")
|
||||
for i, part := range parts {
|
||||
parts[i] = kebabToCamel(part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// kebabToCamel converts a single kebab-case string to camelCase
|
||||
// e.g., "auto-approval-settings" -> "autoApprovalSettings"
|
||||
func kebabToCamel(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) == 1 {
|
||||
return s
|
||||
}
|
||||
|
||||
// First part stays lowercase, rest are capitalized
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
if parts[i] != "" {
|
||||
result += strings.ToUpper(parts[i][:1]) + parts[i][1:]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getNestedValue retrieves a value from a nested map using dot notation
|
||||
// e.g., "autoApprovalSettings.actions.readFiles"
|
||||
func getNestedValue(data map[string]interface{}, parts []string) (interface{}, bool) {
|
||||
current := interface{}(data)
|
||||
|
||||
for _, part := range parts {
|
||||
// Try to access as map
|
||||
if m, ok := current.(map[string]interface{}); ok {
|
||||
if val, exists := m[part]; exists {
|
||||
current = val
|
||||
continue
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return current, true
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sensitiveKeywords defines field name patterns that should be censored
|
||||
var sensitiveKeywords = []string{"key", "secret", "password", "cline-account-id"}
|
||||
|
||||
// camelToKebab converts camelCase to kebab-case
|
||||
// e.g., "autoApprovalSettings" -> "auto-approval-settings"
|
||||
func camelToKebab(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
var result []rune
|
||||
for i, r := range s {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result = append(result, '-')
|
||||
}
|
||||
result = append(result, r|32) // Convert to lowercase (works for A-Z)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// isSensitiveField checks if a field name contains sensitive keywords
|
||||
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
|
||||
}
|
||||
|
||||
// formatValue formats a value for display, handling empty strings and censoring sensitive fields
|
||||
func formatValue(val interface{}, fieldName string, censor bool) string {
|
||||
// Handle empty strings specifically
|
||||
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)
|
||||
}
|
||||
|
||||
// RenderField renders a single config field with proper formatting
|
||||
func RenderField(key string, value interface{}, censor bool) error {
|
||||
switch key {
|
||||
// Nested objects - render with header + nested fields
|
||||
case "apiConfiguration":
|
||||
return renderApiConfiguration(value, censor)
|
||||
case "browserSettings":
|
||||
return renderBrowserSettings(value, censor)
|
||||
case "focusChainSettings":
|
||||
return renderFocusChainSettings(value, censor)
|
||||
case "dictationSettings":
|
||||
return renderDictationSettings(value, censor)
|
||||
case "autoApprovalSettings":
|
||||
return renderAutoApprovalSettings(value, censor)
|
||||
|
||||
// Simple values - just print key: value
|
||||
case "mode", "telemetrySetting", "preferredLanguage", "customPrompt",
|
||||
"defaultTerminalProfile", "mcpDisplayMode", "openaiReasoningEffort",
|
||||
"planActSeparateModelsSetting", "enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled", "terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed", "strictPlanModeEnabled",
|
||||
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold":
|
||||
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown config field: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
// renderApiConfiguration renders the API configuration object
|
||||
func renderApiConfiguration(value interface{}, censor bool) error {
|
||||
fmt.Println("api-configuration:")
|
||||
|
||||
configMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid api-configuration format")
|
||||
}
|
||||
|
||||
// Print each field directly
|
||||
for key, val := range configMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderBrowserSettings renders browser settings
|
||||
func renderBrowserSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("browser-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid browser-settings format")
|
||||
}
|
||||
|
||||
// Handle nested viewport if present
|
||||
if viewport, ok := settingsMap["viewport"].(map[string]interface{}); ok {
|
||||
fmt.Println(" viewport:")
|
||||
for key, val := range viewport {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
// Print other fields
|
||||
for key, val := range settingsMap {
|
||||
if key != "viewport" {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderFocusChainSettings renders focus chain settings
|
||||
func renderFocusChainSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("focus-chain-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid focus-chain-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDictationSettings renders dictation settings
|
||||
func renderDictationSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("dictation-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid dictation-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAutoApprovalSettings renders auto approval settings
|
||||
func renderAutoApprovalSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("auto-approval-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid auto-approval-settings format")
|
||||
}
|
||||
|
||||
// Print top-level fields (skip version, handle actions specially)
|
||||
for key, val := range settingsMap {
|
||||
if key == "version" {
|
||||
continue // Skip version
|
||||
}
|
||||
|
||||
if key == "actions" {
|
||||
// Handle nested actions with double indentation
|
||||
fmt.Println(" actions:")
|
||||
if actionsMap, ok := val.(map[string]interface{}); ok {
|
||||
for actionKey, actionVal := range actionsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(actionKey), formatValue(actionVal, actionKey, censor))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print other fields normally (enabled, maxRequests, enableNotifications, favorites)
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func isTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
func ClearLine() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
// ClearToEnd clears from cursor to end of screen
|
||||
func ClearToEnd() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\033[J")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageDeduplicator handles message deduplication to prevent duplicate displays
|
||||
type MessageDeduplicator struct {
|
||||
mu sync.RWMutex
|
||||
seenMessages map[string]time.Time
|
||||
maxAge time.Duration
|
||||
cleanupTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewMessageDeduplicator creates a new message deduplicator
|
||||
func NewMessageDeduplicator() *MessageDeduplicator {
|
||||
d := &MessageDeduplicator{
|
||||
seenMessages: make(map[string]time.Time),
|
||||
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
|
||||
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go d.cleanup()
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// IsDuplicate checks if a message is a duplicate
|
||||
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Create a hash of the message content
|
||||
hash := d.hashMessage(msg)
|
||||
|
||||
// Check if we've seen this message recently
|
||||
if lastSeen, exists := d.seenMessages[hash]; exists {
|
||||
// If we've seen it within the last few seconds, it's a duplicate
|
||||
if time.Since(lastSeen) < 2*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Mark this message as seen
|
||||
d.seenMessages[hash] = time.Now()
|
||||
return false
|
||||
}
|
||||
|
||||
// hashMessage creates a hash of the message for deduplication
|
||||
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
|
||||
// Create a hash based on message content, type, and timestamp
|
||||
content := fmt.Sprintf("%s|%s|%s|%d",
|
||||
string(msg.Type),
|
||||
msg.Say,
|
||||
msg.Ask,
|
||||
msg.Timestamp)
|
||||
|
||||
// For partial messages, include the text content in the hash
|
||||
if msg.Partial {
|
||||
content += "|" + msg.Text
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(content))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the seen messages map
|
||||
func (d *MessageDeduplicator) cleanup() {
|
||||
for range d.cleanupTicker.C {
|
||||
d.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
// Remove entries older than maxAge
|
||||
for hash, timestamp := range d.seenMessages {
|
||||
if now.Sub(timestamp) > d.maxAge {
|
||||
delete(d.seenMessages, hash)
|
||||
}
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the cleanup goroutine
|
||||
func (d *MessageDeduplicator) Stop() {
|
||||
if d.cleanupTicker != nil {
|
||||
d.cleanupTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type MarkdownRenderer struct {
|
||||
renderer *glamour.TermRenderer
|
||||
width int
|
||||
}
|
||||
|
||||
// i went back and forth on whether or not to enable word wrap
|
||||
// setting line width to 0 enables the terminal to handle wrapping
|
||||
// setting it to a terminal width enables glamour's word wrap
|
||||
// the thing is, glamour's nice indentation looks really good, and
|
||||
// won't work without glamour's word wrap - if you use the terminal's
|
||||
// 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
|
||||
// and if you resize the terminal, you'll learn real quick.
|
||||
// anyway, you can set this to true or false to experiment
|
||||
const USETERMINALWORDWRAP = false
|
||||
|
||||
|
||||
// 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
|
||||
// https://github.com/charmbracelet/glamour/blob/master/styles/README.md)
|
||||
func detectTerminalTheme() string {
|
||||
switch os.Getenv("TERM_PROGRAM") {
|
||||
case "iTerm.app", "Ghostty":
|
||||
return "auto"
|
||||
}
|
||||
if os.Getenv("GHOSTTY_VERSION") != "" {
|
||||
return "auto"
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func glamourStyleJSON(terminalWrap bool) string {
|
||||
const tmpl = `{
|
||||
"document": {
|
||||
"block_prefix": "\n",
|
||||
"block_suffix": "\n",
|
||||
"color": "252",
|
||||
"margin": %s
|
||||
},
|
||||
"code_block": {
|
||||
"margin": 0
|
||||
}
|
||||
}`
|
||||
if terminalWrap {
|
||||
return fmt.Sprintf(tmpl, "0")
|
||||
}
|
||||
return fmt.Sprintf(tmpl, "2")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
|
||||
var wordWrap int
|
||||
if USETERMINALWORDWRAP {
|
||||
// terminal handles wrapping -> disable glamour wrap
|
||||
wordWrap = 0
|
||||
} else {
|
||||
// glamour handles wrapping -> set to current width
|
||||
wordWrap = terminalWidthOr(0)
|
||||
}
|
||||
|
||||
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.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MarkdownRenderer{
|
||||
renderer: r,
|
||||
width: 0, // Unlimited width
|
||||
}, nil
|
||||
}
|
||||
|
||||
// terminalWidthOr returns the terminal width or the provided fallback.
|
||||
// It first tries term.GetSize, then falls back to $COLUMNS if set.
|
||||
func terminalWidthOr(fallback int) int {
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
|
||||
return w
|
||||
}
|
||||
if cols := os.Getenv("COLUMNS"); cols != "" {
|
||||
if n, err := strconv.Atoi(cols); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// NewMarkdownRendererWithWidth creates a markdown renderer with a specific width.
|
||||
// Useful for tables and other content that should fit within terminal bounds.
|
||||
func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle(detectTerminalTheme()),
|
||||
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(false))),
|
||||
glamour.WithWordWrap(width),
|
||||
glamour.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
width := terminalWidthOr(120)
|
||||
return NewMarkdownRendererWithWidth(width)
|
||||
}
|
||||
|
||||
func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
|
||||
rendered, err := mr.renderer.Render(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
func NewRenderer(outputFormat string) *Renderer {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
clean := r.sanitizeText(text)
|
||||
if clean == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if newline {
|
||||
fmt.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
fmt.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatNumber formats numbers with k/m abbreviations
|
||||
func formatNumber(n int) string {
|
||||
if n >= 1000000 {
|
||||
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
|
||||
} else if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
} else {
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine("API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderTaskCancelled() error {
|
||||
markdown := "## Task cancelled"
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
|
||||
startIndex := 0
|
||||
if len(tasks) > maxTasks {
|
||||
startIndex = len(tasks) - maxTasks
|
||||
}
|
||||
|
||||
recentTasks := tasks[startIndex:]
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, task := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", task.Id)
|
||||
|
||||
description := task.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
if i < len(recentTasks)-1 {
|
||||
r.typewriter.PrintfLn("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine("[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove control characters and escape sequences
|
||||
var result strings.Builder
|
||||
for _, r := range text {
|
||||
// Keep printable characters, spaces, tabs, and newlines
|
||||
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip control characters (0-31 except tab, newline, carriage return)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
|
||||
r.typewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
func (r *Renderer) IsTypewriterEnabled() bool {
|
||||
return r.typewriter.IsEnabled()
|
||||
}
|
||||
|
||||
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
r.typewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
|
||||
func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
|
||||
return r.mdRenderer
|
||||
}
|
||||
|
||||
// RenderMarkdown renders markdown text to terminal format with ANSI codes
|
||||
// Falls back to plaintext if markdown rendering is unavailable or fails
|
||||
// Respects output format - skips rendering in plain mode
|
||||
func (r *Renderer) RenderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering in plain mode
|
||||
if r.outputFormat == "plain" {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if r.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := r.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
type StreamingSegment struct {
|
||||
mu sync.Mutex
|
||||
sayType string
|
||||
prefix string
|
||||
buffer strings.Builder
|
||||
frozen bool
|
||||
mdRenderer *MarkdownRenderer
|
||||
toolRenderer *ToolRenderer
|
||||
shouldMarkdown bool
|
||||
outputFormat string
|
||||
msg *types.ClineMessage
|
||||
toolParser *ToolResultParser
|
||||
}
|
||||
|
||||
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
|
||||
ss := &StreamingSegment{
|
||||
sayType: sayType,
|
||||
prefix: prefix,
|
||||
mdRenderer: mdRenderer,
|
||||
toolRenderer: NewToolRenderer(mdRenderer, outputFormat),
|
||||
shouldMarkdown: shouldMarkdown,
|
||||
outputFormat: outputFormat,
|
||||
msg: msg,
|
||||
toolParser: NewToolResultParser(mdRenderer),
|
||||
}
|
||||
|
||||
// Render rich header immediately when creating segment (if in rich mode)
|
||||
if shouldMarkdown && outputFormat != "plain" {
|
||||
header := ss.generateRichHeader()
|
||||
rendered, _ := mdRenderer.Render(header)
|
||||
fmt.Println()
|
||||
fmt.Print(rendered)
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) AppendText(text string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
ss.frozen = true
|
||||
currentBuffer := ss.buffer.String()
|
||||
|
||||
// Render and print the final markdown
|
||||
ss.renderFinal(currentBuffer)
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
var bodyContent string
|
||||
|
||||
// Use ToolRenderer for all body rendering to centralize logic
|
||||
if ss.sayType == "ask" {
|
||||
// Handle ASK messages
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
// Tool approval: use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
// For approval requests in streaming, use the preview method
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentPreview(&tool)
|
||||
}
|
||||
} else if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
// Followup question: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GenerateAskFollowupBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
|
||||
// Plan mode respond: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GeneratePlanModeRespondBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
// Command approval: no body needed - header shows command, output shown separately later
|
||||
bodyContent = ""
|
||||
} else {
|
||||
// For other ask types, render as-is
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeTool) {
|
||||
// Tool execution (SAY): use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool)
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeCommand) {
|
||||
// Command output
|
||||
bodyContent = "```shell\n" + currentBuffer + "\n```"
|
||||
// Render markdown
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
rendered, err := ss.mdRenderer.Render(bodyContent)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other types (reasoning, text, etc.), render markdown as-is
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
rendered, err := ss.mdRenderer.Render(currentBuffer)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
}
|
||||
|
||||
// Print the body content
|
||||
if bodyContent != "" {
|
||||
if !strings.HasSuffix(bodyContent, "\n") {
|
||||
fmt.Print(bodyContent)
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Print(bodyContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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) {
|
||||
return ss.toolRenderer.GeneratePlanModeRespondHeader()
|
||||
}
|
||||
|
||||
// For tool approvals, show proper tool header
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err == nil {
|
||||
// Use ToolRenderer for approval header with "wants to" verbs
|
||||
return ss.toolRenderer.RenderToolApprovalHeader(&tool)
|
||||
}
|
||||
}
|
||||
|
||||
// For command approvals, show command header
|
||||
if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
command := strings.TrimSpace(ss.msg.Text)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
}
|
||||
return fmt.Sprintf("### Cline wants to run `%s`\n", command)
|
||||
}
|
||||
|
||||
// For followup questions, show question header
|
||||
if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
return ss.toolRenderer.GenerateAskFollowupHeader()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// generateToolHeader generates a contextual header for tool operations
|
||||
func (ss *StreamingSegment) generateToolHeader() string {
|
||||
// Parse tool JSON from message text
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil {
|
||||
return "### Tool operation\n"
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer for header
|
||||
return ss.toolRenderer.RenderToolExecutionHeader(&tool)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
activeSegment *StreamingSegment
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to initialize markdown renderer: %v", err))
|
||||
}
|
||||
|
||||
return &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePartialMessage processes partial messages with streaming support
|
||||
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Segment-based header-only streaming
|
||||
// Partial stream only shows headers immediately, state stream will handle content bodies
|
||||
sayType := msg.Say
|
||||
if msg.Type == types.MessageTypeAsk {
|
||||
sayType = "ask"
|
||||
}
|
||||
|
||||
// Detect segment boundary
|
||||
if s.activeSegment != nil && s.activeSegment.sayType != sayType {
|
||||
// Just cleanup, don't freeze (no body to print)
|
||||
s.activeSegment = nil
|
||||
}
|
||||
|
||||
// On first partial message for a new segment type, create segment (prints header)
|
||||
if s.activeSegment == nil && msg.Partial {
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
// NewStreamingSegment prints the header immediately
|
||||
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
// Header printed, done - don't append text or freeze
|
||||
return nil
|
||||
}
|
||||
|
||||
// For subsequent partial messages, do nothing (header already shown)
|
||||
if msg.Partial {
|
||||
return nil
|
||||
}
|
||||
|
||||
// When message is complete (partial=false), render the content body
|
||||
if s.activeSegment != nil {
|
||||
// Had an active segment from partial messages - freeze to render body
|
||||
s.activeSegment.AppendText(msg.Text)
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
} else if !msg.Partial {
|
||||
// Message arrived complete without partial phase - create segment and render immediately
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
segment.AppendText(msg.Text)
|
||||
segment.Freeze()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) getPrefix(sayType string) string {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning):
|
||||
return "THINKING"
|
||||
case string(types.SayTypeText):
|
||||
return "CLINE"
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "RESULT"
|
||||
case "ask":
|
||||
return "ASK"
|
||||
case string(types.SayTypeCommand):
|
||||
return "TERMINAL"
|
||||
default:
|
||||
return strings.ToUpper(sayType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) FreezeActiveSegment() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.activeSegment != nil {
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
s.FreezeActiveSegment()
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
)
|
||||
|
||||
// ErrorSeverity represents the severity level of an error
|
||||
type ErrorSeverity string
|
||||
|
||||
const (
|
||||
SeverityCritical ErrorSeverity = "critical"
|
||||
SeverityWarning ErrorSeverity = "warning"
|
||||
SeverityInfo ErrorSeverity = "info"
|
||||
)
|
||||
|
||||
// SystemMessageRenderer handles rendering of system messages (errors, warnings, info)
|
||||
type SystemMessageRenderer struct {
|
||||
renderer *Renderer
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
// NewSystemMessageRenderer creates a new system message renderer
|
||||
func NewSystemMessageRenderer(renderer *Renderer, mdRenderer *MarkdownRenderer, outputFormat string) *SystemMessageRenderer {
|
||||
return &SystemMessageRenderer{
|
||||
renderer: renderer,
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderError renders a beautiful error message with optional details
|
||||
func (sr *SystemMessageRenderer) RenderError(severity ErrorSeverity, title, body string, details map[string]string) error {
|
||||
var colorMarkdown string
|
||||
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
colorMarkdown = "**[ERROR]**"
|
||||
case SeverityWarning:
|
||||
colorMarkdown = "**[WARNING]**"
|
||||
case SeverityInfo:
|
||||
colorMarkdown = "**[INFO]**"
|
||||
}
|
||||
|
||||
// Build the error message in markdown
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf("### %s %s", colorMarkdown, title)
|
||||
parts = append(parts, header)
|
||||
|
||||
// Body
|
||||
if body != "" {
|
||||
parts = append(parts, "", body)
|
||||
}
|
||||
|
||||
// Details
|
||||
if len(details) > 0 {
|
||||
parts = append(parts, "", "**Details:**")
|
||||
for key, value := range details {
|
||||
parts = append(parts, fmt.Sprintf("- %s: `%s`", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderBalanceError renders a special balance/credits error with helpful info
|
||||
func (sr *SystemMessageRenderer) RenderBalanceError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** Credit Limit Reached")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message from error.details, fallback to main message
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Account Balance section
|
||||
parts = append(parts, "**Account Balance:**")
|
||||
|
||||
// Current balance
|
||||
if balance := err.GetCurrentBalance(); balance != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Current Balance: **$%.2f**", *balance))
|
||||
}
|
||||
|
||||
// Total spent
|
||||
if spent := err.GetTotalSpent(); spent != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Total Spent: $%.2f", *spent))
|
||||
}
|
||||
|
||||
// Promotions applied
|
||||
if promos := err.GetTotalPromotions(); promos != nil {
|
||||
parts = append(parts, fmt.Sprintf("- Promotions Applied: $%.2f", *promos))
|
||||
}
|
||||
|
||||
parts = append(parts, "")
|
||||
|
||||
// Buy credits link
|
||||
if url := err.GetBuyCreditsURL(); url != "" {
|
||||
parts = append(parts, fmt.Sprintf("**→ Buy credits:** %s", url))
|
||||
} else {
|
||||
// Fallback - show both personal and org URLs
|
||||
parts = append(parts, "**→ Buy credits:**")
|
||||
parts = append(parts, " - Personal: https://app.cline.bot/dashboard/account?tab=credits")
|
||||
parts = append(parts, " - Organization: https://app.cline.bot/dashboard/organization?tab=credits")
|
||||
}
|
||||
|
||||
// Request ID (less prominent at the end)
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: %s*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderAuthError renders an authentication error with helpful guidance
|
||||
func (sr *SystemMessageRenderer) RenderAuthError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** Authentication Failed")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Guidance
|
||||
parts = append(parts, "**Next Steps:**")
|
||||
parts = append(parts, "- Check your API key configuration")
|
||||
parts = append(parts, "- Run `cline auth` to authenticate")
|
||||
parts = append(parts, "- Verify your account status at https://app.cline.bot")
|
||||
|
||||
// Request ID
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRateLimitError renders a rate limit error with request ID
|
||||
func (sr *SystemMessageRenderer) RenderRateLimitError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[WARNING]** Rate Limit Reached")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
parts = append(parts, "")
|
||||
|
||||
// Guidance
|
||||
parts = append(parts, "The API will automatically retry this request.")
|
||||
|
||||
// Request ID
|
||||
if err.RequestID != "" {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderAPIError renders a generic API error with all available details
|
||||
func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error {
|
||||
var parts []string
|
||||
|
||||
// Header
|
||||
parts = append(parts, "### **[ERROR]** API Request Failed")
|
||||
parts = append(parts, "")
|
||||
|
||||
// Message - prefer detail message if available
|
||||
message := err.Message
|
||||
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
|
||||
message = detailMsg
|
||||
}
|
||||
parts = append(parts, message)
|
||||
|
||||
// Details
|
||||
var details []string
|
||||
if err.RequestID != "" {
|
||||
details = append(details, fmt.Sprintf("- Request ID: `%s`", err.RequestID))
|
||||
}
|
||||
if code := err.GetCodeString(); code != "" {
|
||||
details = append(details, fmt.Sprintf("- Error Code: `%s`", code))
|
||||
}
|
||||
if err.Status > 0 {
|
||||
details = append(details, fmt.Sprintf("- HTTP Status: `%d`", err.Status))
|
||||
}
|
||||
if err.ModelID != "" {
|
||||
details = append(details, fmt.Sprintf("- Model: `%s`", err.ModelID))
|
||||
}
|
||||
if err.ProviderID != "" {
|
||||
details = append(details, fmt.Sprintf("- Provider: `%s`", err.ProviderID))
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
parts = append(parts, "")
|
||||
parts = append(parts, "**Details:**")
|
||||
parts = append(parts, strings.Join(details, "\n"))
|
||||
}
|
||||
|
||||
markdown := strings.Join(parts, "\n")
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderWarning renders a warning message
|
||||
func (sr *SystemMessageRenderer) RenderWarning(title, message string) error {
|
||||
markdown := fmt.Sprintf("### **[WARNING]** %s\n\n%s", title, message)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderInfo renders an info message
|
||||
func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
|
||||
markdown := fmt.Sprintf("### **[INFO]** %s\n\n%s", title, message)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCheckpoint renders a checkpoint creation message
|
||||
func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error {
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
|
||||
rendered := sr.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolRenderer provides unified rendering for tool and command messages
|
||||
type ToolRenderer struct {
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
// NewToolRenderer creates a new tool renderer
|
||||
func NewToolRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *ToolRenderer {
|
||||
return &ToolRenderer{
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderToolApprovalRequest renders a tool approval request ("Cline wants to...")
|
||||
func (tr *ToolRenderer) RenderToolApprovalRequest(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content preview for relevant tools
|
||||
contentPreview := tr.GenerateToolContentPreview(tool)
|
||||
if contentPreview != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentPreview)
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecution renders a completed tool execution ("Cline is ...ing")
|
||||
func (tr *ToolRenderer) RenderToolExecution(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content body for relevant tools
|
||||
contentBody := tr.GenerateToolContentBody(tool)
|
||||
if contentBody != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentBody)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecutionHeader renders just the header for streaming (no body)
|
||||
func (tr *ToolRenderer) RenderToolExecutionHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
return header
|
||||
}
|
||||
|
||||
// RenderToolApprovalHeader renders just the header for approval requests (no body)
|
||||
func (tr *ToolRenderer) RenderToolApprovalHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
return header
|
||||
}
|
||||
|
||||
// generateToolHeader generates the markdown header for a tool message
|
||||
func (tr *ToolRenderer) generateToolHeader(tool *types.ToolMessage, verbTense string) string {
|
||||
var verb string
|
||||
var action string
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to edit"
|
||||
} else {
|
||||
action = "is editing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to write"
|
||||
} else {
|
||||
action = "is writing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeReadFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to read"
|
||||
} else {
|
||||
action = "is reading"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list files in"
|
||||
} else {
|
||||
action = "is listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to recursively list files in"
|
||||
} else {
|
||||
action = "is recursively listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
if tool.Regex != "" && tool.Path != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s` in `%s`", action, tool.Regex, tool.Path)
|
||||
} else if tool.Regex != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Regex)
|
||||
} else {
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to search files"
|
||||
} else {
|
||||
return "### Cline is searching files"
|
||||
}
|
||||
}
|
||||
|
||||
case string(types.ToolTypeWebFetch):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to fetch"
|
||||
} else {
|
||||
action = "is fetching"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list code definitions in"
|
||||
} else {
|
||||
action = "is listing code definitions in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to condense the conversation"
|
||||
} else {
|
||||
return "### Cline condensed the conversation"
|
||||
}
|
||||
|
||||
default:
|
||||
if verbTense == "wants to" {
|
||||
verb = "wants to use"
|
||||
} else {
|
||||
verb = "is using"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s tool: %s", verb, tool.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentPreview generates content preview for approval requests
|
||||
func (tr *ToolRenderer) GenerateToolContentPreview(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show diff for edits
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show content preview for new files (truncated)
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "..."
|
||||
}
|
||||
previewMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(previewMd)
|
||||
|
||||
case string(types.ToolTypeReadFile), string(types.ToolTypeWebFetch):
|
||||
// No preview for read/fetch operations
|
||||
return ""
|
||||
|
||||
default:
|
||||
// For other tools, show truncated content if available
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
if preview != "" {
|
||||
return fmt.Sprintf("Preview: %s", preview)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentBody generates full content for completed executions
|
||||
func (tr *ToolRenderer) GenerateToolContentBody(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use enhanced tool result parser for supported tools
|
||||
toolParser := NewToolResultParser(tr.mdRenderer)
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeReadFile):
|
||||
// readFile: show header only, no body
|
||||
return ""
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel),
|
||||
string(types.ToolTypeListFilesRecursive),
|
||||
string(types.ToolTypeListCodeDefinitionNames),
|
||||
string(types.ToolTypeSearchFiles),
|
||||
string(types.ToolTypeWebFetch):
|
||||
// Use parser for structured output
|
||||
preview := toolParser.ParseToolResult(tool)
|
||||
return tr.renderMarkdown(preview)
|
||||
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show the diff
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show file content preview
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
contentMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(contentMd)
|
||||
|
||||
default:
|
||||
// For unknown tools, show content as-is
|
||||
if len(tool.Content) > 500 {
|
||||
return tool.Content[:500] + "..."
|
||||
}
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
|
||||
// RenderCommandApprovalRequest renders a command approval request
|
||||
func (tr *ToolRenderer) RenderCommandApprovalRequest(command string, autoApprovalConflict bool) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Clean command
|
||||
command = strings.TrimSpace(command)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
autoApprovalConflict = true
|
||||
}
|
||||
|
||||
// Generate header
|
||||
header := fmt.Sprintf("### Cline wants to run `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Show command in code block
|
||||
cmdBlock := fmt.Sprintf("```shell\n%s\n```", command)
|
||||
cmdRendered := tr.renderMarkdown(cmdBlock)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(cmdRendered)
|
||||
|
||||
// Add warning if needed
|
||||
if autoApprovalConflict {
|
||||
output.WriteString("\nWARNING: The model has determined this command requires explicit approval.\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderCommandExecution renders a command execution announcement
|
||||
func (tr *ToolRenderer) RenderCommandExecution(command string) string {
|
||||
command = strings.TrimSpace(command)
|
||||
header := fmt.Sprintf("### Cline is running `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
return "\n" + rendered + "\n"
|
||||
}
|
||||
|
||||
// RenderCommandOutput renders command output
|
||||
func (tr *ToolRenderer) RenderCommandOutput(output string) string {
|
||||
var result strings.Builder
|
||||
|
||||
header := "### Terminal output"
|
||||
rendered := tr.renderMarkdown(header)
|
||||
result.WriteString("\n")
|
||||
result.WriteString(rendered)
|
||||
result.WriteString("\n\n")
|
||||
|
||||
// Show output in code block
|
||||
outputBlock := fmt.Sprintf("```\n%s\n```", strings.TrimSpace(output))
|
||||
outputRendered := tr.renderMarkdown(outputBlock)
|
||||
result.WriteString(outputRendered)
|
||||
result.WriteString("\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// RenderUserResponse renders user approval/rejection feedback
|
||||
func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string {
|
||||
var symbol, status string
|
||||
|
||||
if approved {
|
||||
symbol = "✓"
|
||||
status = "Approved"
|
||||
} else {
|
||||
symbol = "✗"
|
||||
status = "Rejected"
|
||||
}
|
||||
|
||||
if feedback != "" {
|
||||
return fmt.Sprintf("%s %s with feedback: %s\n", symbol, status, feedback)
|
||||
}
|
||||
return fmt.Sprintf("%s %s\n", symbol, status)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown if not in plain mode
|
||||
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
|
||||
if tr.outputFormat == "plain" {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if tr.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := tr.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// GenerateAskFollowupHeader generates the header for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupHeader() string {
|
||||
return "### Cline has a question\n"
|
||||
}
|
||||
|
||||
// GenerateAskFollowupBody generates the body content for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupBody(messageText string) string {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(messageText), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = messageText
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the question
|
||||
rendered := tr.renderMarkdown(question)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondHeader generates the header for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondHeader() string {
|
||||
return "### Cline has a plan\n"
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondBody generates the body content for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondBody(messageText string) string {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(messageText), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = messageText
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the response
|
||||
rendered := tr.renderMarkdown(response)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolResultParser handles parsing and formatting tool results for display
|
||||
type ToolResultParser struct {
|
||||
maxPreviewLines int
|
||||
maxPreviewChars int
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewToolResultParser creates a new tool result parser
|
||||
func NewToolResultParser(mdRenderer *MarkdownRenderer) *ToolResultParser {
|
||||
return &ToolResultParser{
|
||||
maxPreviewLines: 15,
|
||||
maxPreviewChars: 500,
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseReadFile formats readFile tool results with smart preview
|
||||
func (p *ToolResultParser) ParseReadFile(content, path string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Get file extension for syntax highlighting
|
||||
ext := filepath.Ext(path)
|
||||
lang := p.detectLanguage(ext)
|
||||
|
||||
var preview strings.Builder
|
||||
|
||||
// Show header with line count
|
||||
preview.WriteString(fmt.Sprintf("*%d lines*\n\n", totalLines))
|
||||
|
||||
// Show preview of content
|
||||
previewLines := p.maxPreviewLines
|
||||
if totalLines < previewLines {
|
||||
previewLines = totalLines
|
||||
}
|
||||
|
||||
preview.WriteString(fmt.Sprintf("```%s\n", lang))
|
||||
for i := 0; i < previewLines; i++ {
|
||||
preview.WriteString(lines[i])
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString("...\n")
|
||||
}
|
||||
preview.WriteString("```\n")
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString(fmt.Sprintf("\n*[Content truncated - showing %d of %d lines]*", previewLines, totalLines))
|
||||
}
|
||||
|
||||
return preview.String()
|
||||
}
|
||||
|
||||
// ParseListFiles formats listFiles tool results with directory tree
|
||||
func (p *ToolResultParser) ParseListFiles(content, path string) string {
|
||||
if content == "" || content == "No files found." {
|
||||
return "*No files found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||
|
||||
// Check for truncation message
|
||||
var truncationMsg string
|
||||
lastLine := lines[len(lines)-1]
|
||||
if strings.Contains(lastLine, "File list truncated") {
|
||||
truncationMsg = lastLine
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
totalFiles := len(lines)
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%d %s*\n\n", totalFiles, p.pluralize(totalFiles, "file", "files")))
|
||||
|
||||
// Show up to 20 files in tree format
|
||||
maxShow := 20
|
||||
if totalFiles < maxShow {
|
||||
maxShow = totalFiles
|
||||
}
|
||||
|
||||
result.WriteString("```\n")
|
||||
for i := 0; i < maxShow; i++ {
|
||||
line := lines[i]
|
||||
// Add tree characters for better visualization
|
||||
if strings.HasPrefix(line, "🔒 ") {
|
||||
result.WriteString("├── 🔒 ")
|
||||
result.WriteString(strings.TrimPrefix(line, "🔒 "))
|
||||
} else {
|
||||
result.WriteString("├── ")
|
||||
result.WriteString(line)
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString("└── ...\n")
|
||||
}
|
||||
result.WriteString("```\n")
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d of %d files]*", maxShow, totalFiles))
|
||||
}
|
||||
|
||||
if truncationMsg != "" {
|
||||
result.WriteString(fmt.Sprintf("\n\n*%s*", truncationMsg))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseSearchFiles formats searchFiles tool results with context
|
||||
func (p *ToolResultParser) ParseSearchFiles(content string) string {
|
||||
if content == "" || content == "Found 0 results." {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
// Extract result count from first line
|
||||
firstLine := lines[0]
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%s*\n\n", firstLine))
|
||||
|
||||
// Parse and group results by file
|
||||
var currentFile string
|
||||
var fileResults []string
|
||||
filesShown := 0
|
||||
maxFiles := 5
|
||||
matchesShown := 0
|
||||
maxMatches := 15
|
||||
|
||||
for i := 1; i < len(lines) && filesShown < maxFiles && matchesShown < maxMatches; i++ {
|
||||
line := lines[i]
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a file path (doesn't start with whitespace or line number)
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && strings.Contains(line, ":") {
|
||||
// Save previous file results
|
||||
if currentFile != "" && len(fileResults) > 0 {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
currentFile = line
|
||||
fileResults = []string{}
|
||||
} else if currentFile != "" {
|
||||
// This is a match line
|
||||
fileResults = append(fileResults, strings.TrimSpace(line))
|
||||
matchesShown++
|
||||
}
|
||||
}
|
||||
|
||||
// Add last file's results
|
||||
if currentFile != "" && len(fileResults) > 0 && filesShown < maxFiles {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
// Add truncation notice
|
||||
totalMatches := strings.Count(content, "\n") - 1 // Rough estimate
|
||||
if matchesShown < totalMatches {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d results - see full output for all matches]*", matchesShown))
|
||||
}
|
||||
|
||||
return result.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 {
|
||||
result.WriteString("...\n")
|
||||
break
|
||||
}
|
||||
result.WriteString(match)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
result.WriteString("```\n\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseCodeDefinitions formats listCodeDefinitionNames tool results
|
||||
func (p *ToolResultParser) ParseCodeDefinitions(content string) string {
|
||||
if content == "" || content == "No source code definitions found." {
|
||||
return "*No code definitions found*"
|
||||
}
|
||||
|
||||
// Return the full content as-is
|
||||
return content
|
||||
}
|
||||
|
||||
// ParseWebFetch formats webFetch tool results with content preview
|
||||
func (p *ToolResultParser) ParseWebFetch(content, url string) string {
|
||||
if content == "" {
|
||||
return fmt.Sprintf("*Fetched content from %s (empty response)*", url)
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
// Try to extract title
|
||||
var title string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "##") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
result.WriteString(fmt.Sprintf("**Title:** %s\n\n", title))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
result.WriteString(strings.Join(previewLines, " "))
|
||||
result.WriteString("...\n\n")
|
||||
|
||||
// Extract sections
|
||||
sections := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "##") {
|
||||
section := strings.TrimSpace(strings.TrimPrefix(trimmed, "##"))
|
||||
sections = append(sections, section)
|
||||
if len(sections) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(sections) > 0 {
|
||||
result.WriteString("**Sections Found:**\n")
|
||||
for _, section := range sections {
|
||||
result.WriteString(fmt.Sprintf("- %s\n", section))
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
// Word count estimate
|
||||
wordCount := len(strings.Fields(content))
|
||||
result.WriteString(fmt.Sprintf("*[Full content: ~%s]*", p.formatWordCount(wordCount)))
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// detectLanguage returns syntax highlighting language based on file extension
|
||||
func (p *ToolResultParser) detectLanguage(ext string) string {
|
||||
langMap := map[string]string{
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".js": "javascript",
|
||||
".jsx": "jsx",
|
||||
".go": "go",
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cs": "csharp",
|
||||
".php": "php",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".xml": "xml",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".md": "markdown",
|
||||
".sql": "sql",
|
||||
".rs": "rust",
|
||||
}
|
||||
|
||||
if lang, ok := langMap[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pluralize returns the correct plural form
|
||||
func (p *ToolResultParser) pluralize(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return singular
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
// formatWordCount formats word count with appropriate unit
|
||||
func (p *ToolResultParser) formatWordCount(count int) string {
|
||||
if count < 1000 {
|
||||
return fmt.Sprintf("%d words", count)
|
||||
}
|
||||
return fmt.Sprintf("%.1fk words", float64(count)/1000.0)
|
||||
}
|
||||
|
||||
// ParseToolResult is the main entry point for parsing tool results
|
||||
func (p *ToolResultParser) ParseToolResult(tool *types.ToolMessage) string {
|
||||
switch tool.Tool {
|
||||
case "readFile":
|
||||
return p.ParseReadFile(tool.Content, tool.Path)
|
||||
case "listFilesTopLevel", "listFilesRecursive":
|
||||
return p.ParseListFiles(tool.Content, tool.Path)
|
||||
case "searchFiles":
|
||||
return p.ParseSearchFiles(tool.Content)
|
||||
case "listCodeDefinitionNames":
|
||||
return p.ParseCodeDefinitions(tool.Content)
|
||||
case "webFetch":
|
||||
return p.ParseWebFetch(tool.Content, tool.Path)
|
||||
default:
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TypewriterConfig holds configuration for the typewriter effect
|
||||
type TypewriterConfig struct {
|
||||
BaseDelay time.Duration // Base delay between characters
|
||||
FastDelay time.Duration // Faster delay for common characters
|
||||
SlowDelay time.Duration // Slower delay for punctuation
|
||||
PauseDelay time.Duration // Pause after sentences
|
||||
Enabled bool // Whether typewriter effect is enabled
|
||||
RandomFactor float64 // Randomness factor (0.0 to 1.0)
|
||||
}
|
||||
|
||||
// DefaultTypewriterConfig returns the default typewriter configuration
|
||||
func DefaultTypewriterConfig() *TypewriterConfig {
|
||||
return &TypewriterConfig{
|
||||
BaseDelay: 15 * time.Millisecond,
|
||||
FastDelay: 8 * time.Millisecond,
|
||||
SlowDelay: 25 * time.Millisecond,
|
||||
PauseDelay: 150 * time.Millisecond,
|
||||
Enabled: false,
|
||||
RandomFactor: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
// TypewriterPrinter handles typewriter-style output
|
||||
type TypewriterPrinter struct {
|
||||
config *TypewriterConfig
|
||||
}
|
||||
|
||||
// NewTypewriterPrinter creates a new typewriter printer
|
||||
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
|
||||
if config == nil {
|
||||
config = DefaultTypewriterConfig()
|
||||
}
|
||||
return &TypewriterPrinter{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Print prints text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Print(text string) {
|
||||
if !tp.config.Enabled {
|
||||
fmt.Print(text)
|
||||
return
|
||||
}
|
||||
|
||||
tp.typewriterPrint(text)
|
||||
}
|
||||
|
||||
// Printf prints formatted text with typewriter effect
|
||||
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Print(text)
|
||||
}
|
||||
|
||||
// Println prints text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) Println(text string) {
|
||||
tp.Print(text + "\n")
|
||||
}
|
||||
|
||||
// PrintfLn prints formatted text with typewriter effect and adds a newline
|
||||
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
|
||||
text := fmt.Sprintf(format, args...)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// PrintInstant prints text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintInstant(text string) {
|
||||
fmt.Print(text)
|
||||
}
|
||||
|
||||
// PrintfInstant prints formatted text immediately without typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (tp *TypewriterPrinter) typewriterPrint(text string) {
|
||||
// Convert string to runes to handle Unicode properly
|
||||
runes := []rune(text)
|
||||
|
||||
for i, r := range runes {
|
||||
// Print the character
|
||||
fmt.Print(string(r))
|
||||
os.Stdout.Sync() // Force immediate output
|
||||
|
||||
// Don't add delay after the last character
|
||||
if i == len(runes)-1 {
|
||||
break
|
||||
}
|
||||
|
||||
// Determine delay based on character type
|
||||
delay := tp.getDelayForCharacter(r, i)
|
||||
|
||||
// Sleep for the calculated delay
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// getDelayForCharacter returns the appropriate delay for a character
|
||||
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
|
||||
var baseDelay time.Duration
|
||||
|
||||
switch {
|
||||
case r == '.' || r == '!' || r == '?':
|
||||
// Longer pause after sentence endings
|
||||
baseDelay = tp.config.PauseDelay
|
||||
case r == ',' || r == ';' || r == ':':
|
||||
// Medium pause after punctuation
|
||||
baseDelay = tp.config.SlowDelay
|
||||
case r == ' ':
|
||||
// Slightly faster for spaces
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
|
||||
// Fast for common letters
|
||||
baseDelay = tp.config.FastDelay
|
||||
case r == '\n':
|
||||
// No delay for newlines
|
||||
return 0
|
||||
default:
|
||||
// Base delay for other characters
|
||||
baseDelay = tp.config.BaseDelay
|
||||
}
|
||||
|
||||
// Add randomness to make it feel more natural
|
||||
if tp.config.RandomFactor > 0 {
|
||||
// Simple pseudo-random based on position to ensure consistency
|
||||
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
|
||||
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
|
||||
}
|
||||
|
||||
return baseDelay
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the typewriter effect
|
||||
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
|
||||
tp.config.Enabled = enabled
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the typewriter effect is enabled
|
||||
func (tp *TypewriterPrinter) IsEnabled() bool {
|
||||
return tp.config.Enabled
|
||||
}
|
||||
|
||||
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
|
||||
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
if multiplier <= 0 {
|
||||
multiplier = 1.0
|
||||
}
|
||||
|
||||
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
|
||||
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
|
||||
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
|
||||
tp.PrintfInstant("%s: ", prefix)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
// Global typewriter printer instance
|
||||
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
|
||||
|
||||
// Global convenience functions that use the global typewriter instance
|
||||
|
||||
// TypewriterPrint prints text with typewriter effect using the global instance
|
||||
func TypewriterPrint(text string) {
|
||||
globalTypewriter.Print(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
|
||||
func TypewriterPrintf(format string, args ...interface{}) {
|
||||
globalTypewriter.Printf(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintln(text string) {
|
||||
globalTypewriter.Println(text)
|
||||
}
|
||||
|
||||
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
|
||||
func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
func TypewriterPrintMessageLine(prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
func SetGlobalTypewriterEnabled(enabled bool) {
|
||||
globalTypewriter.SetEnabled(enabled)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
|
||||
func SetGlobalTypewriterSpeed(multiplier float64) {
|
||||
globalTypewriter.SetSpeed(multiplier)
|
||||
}
|
||||
|
||||
// GetGlobalTypewriter returns the global typewriter instance
|
||||
func GetGlobalTypewriter() *TypewriterPrinter {
|
||||
return globalTypewriter
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
type ClineClients struct {
|
||||
registry *ClientRegistry
|
||||
}
|
||||
|
||||
// NewClineClients creates a new ClineClients instance
|
||||
func NewClineClients(configPath string) *ClineClients {
|
||||
registry := NewClientRegistry(configPath)
|
||||
return &ClineClients{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
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
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
|
||||
// Check if the specified core port is available
|
||||
if common.IsInstanceHealthy(ctx, coreAddress) {
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
// Start cline-core (it will register itself in SQLite locks database)
|
||||
coreCmd, err := startClineCore(corePort, hostPort)
|
||||
if err != nil {
|
||||
// Clean up host process if core fails to start
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
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
|
||||
err = common.RetryOperation(12, 5*time.Second, func() error {
|
||||
// Check if instance registered itself in SQLite
|
||||
foundInstance, err := c.registry.GetInstance(fullAddress)
|
||||
if err != nil || foundInstance == nil {
|
||||
return fmt.Errorf("instance not found in registry: %v", err)
|
||||
}
|
||||
|
||||
// Verify instance is healthy
|
||||
if !common.IsInstanceHealthy(ctx, fullAddress) {
|
||||
return fmt.Errorf("instance is registered but not healthy")
|
||||
}
|
||||
|
||||
// Success - store the instance for return
|
||||
instance = foundInstance
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Clean up both processes on failure
|
||||
if coreCmd != nil && coreCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
|
||||
coreCmd.Process.Kill()
|
||||
}
|
||||
if hostCmd != nil && hostCmd.Process != nil {
|
||||
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
|
||||
hostCmd.Process.Kill()
|
||||
}
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
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 {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineClients) GetRegistry() *ClientRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
|
||||
}
|
||||
|
||||
// Check if instance already exists at this address
|
||||
if c.registry.HasInstanceAtAddress(normalized) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, port, err := common.ParseHostPort(normalized)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
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()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
clineHostPath := path.Join(binDir, "cline-host")
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command(clineHostPath,
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped log file
|
||||
timestamp := time.Now().Format("2006-01-02-15-04-05")
|
||||
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
|
||||
logFilePath := path.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// KillInstanceByAddress kills a Cline instance by its address
|
||||
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
}
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
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) {
|
||||
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)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Get paths relative to the cline binary location
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
installDir := path.Dir(binDir)
|
||||
nodePath := path.Join(binDir, "node")
|
||||
clineCorePath := path.Join(installDir, "cline-core.js")
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped log file
|
||||
timestamp := time.Now().Format("2006-01-02-15-04-05")
|
||||
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
|
||||
logFilePath := path.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag
|
||||
args := []string{clineCorePath,
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
cmd := exec.Command(nodePath, args...)
|
||||
|
||||
// Set working directory to installation root
|
||||
cmd.Dir = installDir
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
// Set environment variables with NODE_PATH for node_modules
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
fmt.Sprintf("NODE_PATH=%s", path.Join(installDir, "node_modules")),
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
|
||||
type GlobalConfig struct {
|
||||
ConfigPath string
|
||||
Verbose bool
|
||||
OutputFormat string
|
||||
CoreAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
if cfg.ConfigPath == "" {
|
||||
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
|
||||
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Clients.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
// User specified a specific address, use that
|
||||
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Clients.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance exists
|
||||
func EnsureDefaultInstance(ctx context.Context) error {
|
||||
if Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
registry := Clients.GetRegistry()
|
||||
|
||||
// First, check if there are any instances already registered in SQLite
|
||||
instances := registry.ListInstances()
|
||||
|
||||
// Use the registry's EnsureDefaultInstance to auto-set first instance as default if needed
|
||||
if err := registry.EnsureDefaultInstance(instances); err != nil {
|
||||
return fmt.Errorf("failed to ensure default from existing instances: %w", err)
|
||||
}
|
||||
|
||||
// Now check if we have a default set
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No instances exist, start a new one
|
||||
// Note: StartNewInstance will automatically set it as default since it's the first instance
|
||||
_, err := Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package global
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/sqlite"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ClientRegistry manages Cline client connections using direct SQLite operations
|
||||
type ClientRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry(configPath string) *ClientRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &ClientRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return defaultAddr
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
}
|
||||
|
||||
return sqlite.SetDefaultInstance(r.configPath, address)
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
|
||||
return r.lockManager.GetInstanceInfo(address)
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
}
|
||||
|
||||
// Create client on-demand (no caching)
|
||||
target, err := common.NormalizeAddressForGRPC(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", address, err)
|
||||
}
|
||||
|
||||
cl, err := client.NewClineClient(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
|
||||
}
|
||||
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
// Check if the default instance actually exists in the database
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(defaultAddr)
|
||||
if err != nil {
|
||||
// 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) {
|
||||
fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr)
|
||||
} else {
|
||||
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 != "" {
|
||||
fmt.Printf("Set new default instance: %s\n", newDefaultAddr)
|
||||
return r.GetClient(ctx, newDefaultAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
// Use context with timeout for health checks
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to list instances: %v\n", err)
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all instances with health checks
|
||||
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances for cleanup: %w", err)
|
||||
}
|
||||
|
||||
// Clean up all stale instances
|
||||
for _, instance := range instances {
|
||||
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
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
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
// Best effort, don't throw errors i guess
|
||||
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create gRPC connection to host bridge
|
||||
conn, err := grpc.DialContext(ctx, hostServiceAddress,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock())
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create env service client and call shutdown
|
||||
envClient := host.NewEnvServiceClient(conn)
|
||||
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
|
||||
} else {
|
||||
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
// 2. Get all instances with real-time health checks
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
if len(instances) == 0 {
|
||||
if currentDefault != "" {
|
||||
// Remove the settings file since no instances exist
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
_ = os.Remove(settingsPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.Address == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
type AskHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewAskHandler creates a new ASK handler
|
||||
func NewAskHandler() *AskHandler {
|
||||
return &AskHandler{
|
||||
BaseHandler: NewBaseHandler("ask", PriorityHigh),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is an ASK message
|
||||
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Always display approval messages so user can see what they're approving
|
||||
// The input handler will show the approval prompt form after the content is displayed
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Use ToolRenderer for unified rendering
|
||||
header := dc.ToolRenderer.GenerateAskFollowupHeader()
|
||||
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the body content
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
if body != "" {
|
||||
fmt.Print(body)
|
||||
}
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
header := dc.ToolRenderer.GeneratePlanModeRespondHeader()
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
|
||||
fmt.Printf("%s", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Try to parse as ClineError for better error display
|
||||
clineErr, _ := clerror.ParseClineError(msg.Text)
|
||||
if clineErr != nil {
|
||||
if dc.SystemRenderer != nil {
|
||||
// Render the error with system renderer
|
||||
switch clineErr.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
dc.SystemRenderer.RenderBalanceError(clineErr)
|
||||
case clerror.ErrorTypeAuth:
|
||||
dc.SystemRenderer.RenderAuthError(clineErr)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
dc.SystemRenderer.RenderRateLimitError(clineErr)
|
||||
default:
|
||||
dc.SystemRenderer.RenderAPIError(clineErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Fallback: render with basic renderer using parsed message
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", clineErr.Message), true)
|
||||
}
|
||||
// Last resort: display raw text if parsing completely failed
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
details := make(map[string]string)
|
||||
if msg.Text != "" {
|
||||
details["details"] = msg.Text
|
||||
}
|
||||
dc.SystemRenderer.RenderError(
|
||||
"critical",
|
||||
"Mistake Limit Reached",
|
||||
"Cline has made too many consecutive mistakes and needs your guidance to proceed.",
|
||||
details,
|
||||
)
|
||||
fmt.Printf("\n**Approval required to continue.**\n")
|
||||
return nil
|
||||
}
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
details := make(map[string]string)
|
||||
if msg.Text != "" {
|
||||
details["reason"] = msg.Text
|
||||
}
|
||||
dc.SystemRenderer.RenderError(
|
||||
"warning",
|
||||
"Auto-Approval Limit Reached",
|
||||
"The maximum number of auto-approved requests has been reached. Manual approval is now required.",
|
||||
details,
|
||||
)
|
||||
fmt.Printf("\n**Approval required to continue.**\n")
|
||||
return nil
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Type string `json:"type"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage("MCP", msg.Text, true)
|
||||
}
|
||||
|
||||
var operation string
|
||||
if mcpReq.Type == "access_mcp_resource" {
|
||||
operation = "access a resource"
|
||||
} else {
|
||||
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
|
||||
if mcpReq.Arguments != "" {
|
||||
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage("MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
StepsToReproduce string `json:"steps_to_reproduce"`
|
||||
APIRequestOutput string `json:"api_request_output"`
|
||||
AdditionalContext string `json:"additional_context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n**Title**: %s\n", bugData.Title)
|
||||
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
|
||||
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
|
||||
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
|
||||
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
|
||||
fmt.Printf("\nApprove to create a GitHub issue.\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// MessageHandler defines the interface for handling different message types
|
||||
type MessageHandler interface {
|
||||
// CanHandle returns true if this handler can process the given message
|
||||
CanHandle(msg *types.ClineMessage) bool
|
||||
|
||||
// Handle processes the message and renders it using the display context
|
||||
Handle(msg *types.ClineMessage, dc *DisplayContext) error
|
||||
|
||||
// GetPriority returns the priority of this handler (higher = more priority)
|
||||
GetPriority() int
|
||||
|
||||
// GetName returns a human-readable name for this handler
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
ToolRenderer *display.ToolRenderer
|
||||
SystemRenderer *display.SystemMessageRenderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
IsStreamingMode bool
|
||||
IsInteractive bool
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
type BaseHandler struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
// NewBaseHandler creates a new base handler
|
||||
func NewBaseHandler(name string, priority int) *BaseHandler {
|
||||
return &BaseHandler{
|
||||
name: name,
|
||||
priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the handler name
|
||||
func (h *BaseHandler) GetName() string {
|
||||
return h.name
|
||||
}
|
||||
|
||||
// GetPriority returns the handler priority
|
||||
func (h *BaseHandler) GetPriority() int {
|
||||
return h.priority
|
||||
}
|
||||
|
||||
// HandlerRegistry manages a collection of message handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers []MessageHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
return &HandlerRegistry{
|
||||
handlers: make([]MessageHandler, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a handler to the registry
|
||||
func (r *HandlerRegistry) Register(handler MessageHandler) {
|
||||
r.handlers = append(r.handlers, handler)
|
||||
|
||||
// Sort handlers by priority (highest first)
|
||||
for i := len(r.handlers) - 1; i > 0; i-- {
|
||||
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
|
||||
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finds the appropriate handler and processes the message
|
||||
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.CanHandle(msg) {
|
||||
return handler.Handle(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific handler found, use default text handler
|
||||
return r.handleDefault(msg, dc)
|
||||
}
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
|
||||
return r.handlers
|
||||
}
|
||||
|
||||
// GetHandlerByName finds a handler by name
|
||||
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
|
||||
for _, handler := range r.handlers {
|
||||
if handler.GetName() == name {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandlerPriorities defines standard priority levels for handlers
|
||||
const (
|
||||
PriorityHigh = 100
|
||||
PriorityNormal = 50
|
||||
PriorityLow = 10
|
||||
)
|
||||
@@ -0,0 +1,520 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
type SayHandler struct {
|
||||
*BaseHandler
|
||||
}
|
||||
|
||||
// NewSayHandler creates a new SAY handler
|
||||
func NewSayHandler() *SayHandler {
|
||||
return &SayHandler{
|
||||
BaseHandler: NewBaseHandler("say", PriorityNormal),
|
||||
}
|
||||
}
|
||||
|
||||
// CanHandle returns true if this is a SAY message
|
||||
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsSay()
|
||||
}
|
||||
|
||||
// Handle processes SAY messages
|
||||
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc)
|
||||
case string(types.SayTypeErrorRetry):
|
||||
return h.handleErrorRetry(msg, dc)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ERROR", msg.Text, true)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage("API INFO", msg.Text, true)
|
||||
}
|
||||
|
||||
// Check for streaming failed message with error details
|
||||
if apiInfo.StreamingFailedMessage != "" {
|
||||
clineErr, _ := clerror.ParseClineError(apiInfo.StreamingFailedMessage)
|
||||
if clineErr != nil {
|
||||
return h.renderClineError(clineErr, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI("request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI("processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// renderClineError renders a ClineError with appropriate formatting based on type
|
||||
func (h *SayHandler) renderClineError(err *clerror.ClineError, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer == nil {
|
||||
return dc.Renderer.RenderMessage("ERROR", err.Message, true)
|
||||
}
|
||||
|
||||
switch err.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
return dc.SystemRenderer.RenderBalanceError(err)
|
||||
case clerror.ErrorTypeAuth:
|
||||
return dc.SystemRenderer.RenderAuthError(err)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
return dc.SystemRenderer.RenderRateLimitError(err)
|
||||
default:
|
||||
return dc.SystemRenderer.RenderAPIError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
if dc.MessageIndex == 0 {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
fmt.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular Cline text response
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text != "" {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage("USER DIFF", message, true)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
|
||||
}
|
||||
|
||||
// handleErrorRetry handles error retry status messages
|
||||
func (h *SayHandler) handleErrorRetry(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse retry info from message text
|
||||
type ErrorRetryInfo struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySeconds int `json:"delaySeconds"`
|
||||
Failed bool `json:"failed"`
|
||||
}
|
||||
|
||||
var retryInfo ErrorRetryInfo
|
||||
if err := json.Unmarshal([]byte(msg.Text), &retryInfo); err != nil {
|
||||
// Fallback to simple message if parsing fails
|
||||
return dc.Renderer.RenderMessage("API INFO", "Auto-retry in progress", true)
|
||||
}
|
||||
|
||||
if retryInfo.Failed {
|
||||
// Retry failed after max attempts
|
||||
message := fmt.Sprintf("Auto-retry failed after %d attempts. Manual intervention required.", retryInfo.MaxAttempts)
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning("Auto-Retry Failed", message)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", message, true)
|
||||
}
|
||||
|
||||
// Retry in progress
|
||||
message := fmt.Sprintf("Attempt %d/%d - Retrying in %d seconds...",
|
||||
retryInfo.Attempt, retryInfo.MaxAttempts, retryInfo.DelaySeconds)
|
||||
return dc.Renderer.RenderMessage("API INFO", message, true)
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true)
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionData struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate string `json:"coordinate,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage("BROWSER", msg.Text, true)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BrowserActionResult struct {
|
||||
Screenshot string `json:"screenshot,omitempty"`
|
||||
Logs string `json:"logs,omitempty"`
|
||||
CurrentUrl string `json:"currentUrl,omitempty"`
|
||||
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
|
||||
}
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Sending request to server", true)
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Server operation approved", true)
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning(
|
||||
"Diff Edit Failure",
|
||||
"The model used search patterns that don't match anything in the file. Retrying...",
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Don't render - this is internal metadata (aggregated API metrics from deleted checkpoint messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderInfo(
|
||||
"Access Denied",
|
||||
fmt.Sprintf("Cline tried to access `%s` which is blocked by the .clineignore file.", msg.Text),
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderCheckpoint(timestamp, msg.Timestamp)
|
||||
}
|
||||
// Fallback to basic renderer if SystemRenderer not available
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderInfo("MCP", "Loading MCP documentation")
|
||||
}
|
||||
return dc.Renderer.RenderMessage("INFO", "Loading MCP documentation", true)
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
client2 "github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
const (
|
||||
platformCLI = "CLI"
|
||||
platformJetBrains = "JetBrains"
|
||||
platformNA = "N/A"
|
||||
hostPlatformCLI = "Cline CLI" // Value returned by host bridge for CLI instances
|
||||
)
|
||||
|
||||
// detectInstancePlatform connects to an instance's host bridge and determines its platform
|
||||
func detectInstancePlatform(ctx context.Context, instance *common.CoreInstanceInfo) (string, error) {
|
||||
hostTarget, err := common.NormalizeAddressForGRPC(instance.HostServiceAddress)
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
hostClient, err := client2.NewClineClient(hostTarget)
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
defer hostClient.Disconnect()
|
||||
|
||||
if err := hostClient.Connect(ctx); err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
hostVersion, err := hostClient.Env.GetHostVersion(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return platformNA, err
|
||||
}
|
||||
|
||||
if hostVersion.Platform == nil {
|
||||
return platformNA, fmt.Errorf("host returned nil platform")
|
||||
}
|
||||
|
||||
platformStr := *hostVersion.Platform
|
||||
if platformStr == hostPlatformCLI {
|
||||
return platformCLI, nil
|
||||
}
|
||||
return platformJetBrains, nil
|
||||
}
|
||||
|
||||
func NewInstanceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instance",
|
||||
Aliases: []string{"i"},
|
||||
Short: "Manage Cline instances",
|
||||
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAllCLI bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
Aliases: []string{"k"},
|
||||
Short: "Kill a Cline instance by address",
|
||||
Long: `Kill a running Cline instance and clean up its registry entry.`,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if killAllCLI && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all-cli flag and address argument")
|
||||
}
|
||||
if !killAllCLI && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all-cli is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if killAllCLI {
|
||||
return killAllCLIInstances(ctx, registry)
|
||||
} else {
|
||||
return global.KillInstanceByAddress(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&killAllCLI, "all-cli", "a", false, "kill all running CLI instances (excludes JetBrains)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
fmt.Println("No Cline instances found to kill.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter to only CLI instances
|
||||
var cliInstances []*common.CoreInstanceInfo
|
||||
var skippedNonCLI int
|
||||
for _, instance := range instances {
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
platform, err := detectInstancePlatform(ctx, instance)
|
||||
if err == nil {
|
||||
if platform == platformCLI {
|
||||
cliInstances = append(cliInstances, instance)
|
||||
} else {
|
||||
skippedNonCLI++
|
||||
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(cliInstances) == 0 {
|
||||
if skippedNonCLI > 0 {
|
||||
fmt.Printf("No CLI instances to kill. Skipped %d JetBrains instance(s).\n", skippedNonCLI)
|
||||
} else {
|
||||
fmt.Println("No CLI instances found to kill.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Kill all CLI instances
|
||||
for _, instance := range cliInstances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all instances to clean up their registry entries
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
|
||||
maxWaitTime := 10 // seconds
|
||||
for i := 0; i < maxWaitTime; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
remainingInstances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to check registry status: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(remainingInstances) == 0 {
|
||||
fmt.Printf("✓ All instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d instances still in registry after %d seconds\n", len(remainingInstances), maxWaitTime)
|
||||
for _, remaining := range remainingInstances {
|
||||
fmt.Printf(" - %s\n", remaining.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
successful := 0
|
||||
failed := 0
|
||||
alreadyDead := 0
|
||||
|
||||
for _, result := range killResults {
|
||||
if result.err != nil {
|
||||
failed++
|
||||
} else if result.alreadyDead {
|
||||
alreadyDead++
|
||||
} else {
|
||||
successful++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSummary: ")
|
||||
if successful > 0 {
|
||||
fmt.Printf("Successfully killed %d instances. ", successful)
|
||||
}
|
||||
if alreadyDead > 0 {
|
||||
fmt.Printf("%d were already dead. ", alreadyDead)
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Printf("%d failures.", failed)
|
||||
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type killResult struct {
|
||||
address string
|
||||
pid int
|
||||
alreadyDead bool
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return killResult{address: address, alreadyDead: true, err: nil}
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return killResult{address: address, pid: pid, err: err}
|
||||
}
|
||||
|
||||
return killResult{address: address, pid: pid, err: nil}
|
||||
}
|
||||
|
||||
func newInstanceListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
|
||||
if len(instances) == 0 {
|
||||
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 instanceRow struct {
|
||||
address string
|
||||
status string
|
||||
version string
|
||||
lastSeen string
|
||||
pid string
|
||||
platform string
|
||||
isDefault string
|
||||
}
|
||||
|
||||
var rows []instanceRow
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
isDefault = "✓"
|
||||
}
|
||||
|
||||
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
|
||||
platform := platformNA
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Get PID from core
|
||||
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
|
||||
instance.Version = *processInfo.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get platform from host bridge
|
||||
if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil {
|
||||
platform = detectedPlatform
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, instanceRow{
|
||||
address: instance.Address,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
pid: pid,
|
||||
platform: platform,
|
||||
isDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 _, row := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.platform,
|
||||
row.isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
} else {
|
||||
// Use markdown table for rich output
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **PLATFORM** | **DEFAULT** |\n")
|
||||
markdown.WriteString("|---------|--------|---------|-----------|-----|----------|---------|")
|
||||
|
||||
for _, row := range rows {
|
||||
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s | %s |",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.platform,
|
||||
row.isDefault,
|
||||
))
|
||||
}
|
||||
|
||||
// Render the markdown table with terminal width for nice table layout
|
||||
renderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain table if markdown renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
rendered, err := renderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
// Post-process to colorize status values
|
||||
rendered = strings.ReplaceAll(rendered, "SERVING", "\033[32mSERVING\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "✓", "\033[32m✓\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", "\033[31mNOT_SERVING\033[0m") // Red
|
||||
rendered = strings.ReplaceAll(rendered, "UNKNOWN", "\033[33mUNKNOWN\033[0m") // Yellow
|
||||
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
}
|
||||
fmt.Println("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <address>",
|
||||
Aliases: []string{"u"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
|
||||
}
|
||||
|
||||
// Set as default
|
||||
if err := registry.SetDefaultInstance(address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Switched to instance: %s\n", address)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new Cline instance",
|
||||
Long: `Create a new Cline instance with automatically assigned ports.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
// Check if this is now the default instance
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type logFileInfo struct {
|
||||
name string
|
||||
path string
|
||||
size int64
|
||||
created time.Time
|
||||
}
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs",
|
||||
Aliases: []string{"log", "l"},
|
||||
Short: "Manage Cline log files",
|
||||
Long: `List and manage log files created by Cline instances.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newLogsListCommand())
|
||||
cmd.AddCommand(newLogsCleanCommand())
|
||||
cmd.AddCommand(newLogsPathCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l", "ls"},
|
||||
Short: "List all log files",
|
||||
Long: `List all log files in the Cline logs directory with their sizes and ages.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
logs, err := listLogFiles(logsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list log files: %w", err)
|
||||
}
|
||||
|
||||
if len(logs) == 0 {
|
||||
fmt.Println("No log files found.")
|
||||
fmt.Printf("Log files will be created in: %s\n", logsDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
return renderLogsTable(logs, false)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsCleanCommand() *cobra.Command {
|
||||
var olderThan int
|
||||
var all bool
|
||||
var dryRun bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "clean",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Delete old log files",
|
||||
Long: `Delete log files older than a specified number of days.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
logs, err := listLogFiles(logsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list log files: %w", err)
|
||||
}
|
||||
|
||||
var toDelete []logFileInfo
|
||||
if all {
|
||||
toDelete = logs
|
||||
} else {
|
||||
toDelete = filterOldLogs(logs, olderThan)
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
if all {
|
||||
fmt.Println("No log files to delete.")
|
||||
} else {
|
||||
fmt.Printf("No log files older than %d days found.\n", olderThan)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
var totalSize int64
|
||||
for _, log := range toDelete {
|
||||
totalSize += log.size
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("The following log files will be deleted:\n")
|
||||
if err := renderLogsTable(toDelete, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fileWord := "files"
|
||||
if len(toDelete) == 1 {
|
||||
fileWord = "file"
|
||||
}
|
||||
fmt.Printf("\nSummary: %d %s will be deleted (%s freed)\n", len(toDelete), fileWord, formatFileSize(totalSize))
|
||||
fmt.Println("\nRun without --dry-run to actually delete these files.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Actually delete the files
|
||||
count, bytesFreed, err := deleteLogFiles(toDelete)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete log files: %w", err)
|
||||
}
|
||||
|
||||
fileWord := "files"
|
||||
if count == 1 {
|
||||
fileWord = "file"
|
||||
}
|
||||
fmt.Printf("Deleted %d log %s (%s freed)\n", count, fileWord, formatFileSize(bytesFreed))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVar(&olderThan, "older-than", 7, "delete logs older than N days")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "delete all log files")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogsPathCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "path",
|
||||
Short: "Print the logs directory path",
|
||||
Long: `Print the absolute path to the Cline logs directory.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Config == nil {
|
||||
return fmt.Errorf("config not initialized")
|
||||
}
|
||||
|
||||
logsDir := filepath.Join(global.Config.ConfigPath, "logs")
|
||||
fmt.Println(logsDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func listLogFiles(logsDir string) ([]logFileInfo, error) {
|
||||
// Check if logs directory exists
|
||||
if _, err := os.Stat(logsDir); os.IsNotExist(err) {
|
||||
return []logFileInfo{}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []logFileInfo
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process .log files
|
||||
if !strings.HasSuffix(entry.Name(), ".log") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp from filename
|
||||
created, err := parseTimestampFromFilename(entry.Name())
|
||||
if err != nil {
|
||||
// Skip files we can't parse
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logs = append(logs, logFileInfo{
|
||||
name: entry.Name(),
|
||||
path: filepath.Join(logsDir, entry.Name()),
|
||||
size: info.Size(),
|
||||
created: created,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by created time (newest first)
|
||||
sort.Slice(logs, func(i, j int) bool {
|
||||
return logs[i].created.After(logs[j].created)
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func parseTimestampFromFilename(filename string) (time.Time, error) {
|
||||
// Expected format: cline-core-2025-10-12-21-30-45-localhost-51051.log
|
||||
// or: cline-host-2025-10-12-21-30-45-localhost-52051.log
|
||||
|
||||
parts := strings.Split(filename, "-")
|
||||
if len(parts) < 8 {
|
||||
return time.Time{}, fmt.Errorf("invalid filename format")
|
||||
}
|
||||
|
||||
// Extract timestamp parts: YYYY-MM-DD-HH-mm-ss
|
||||
// They should be at indices 2-7
|
||||
timestampStr := strings.Join(parts[2:8], "-")
|
||||
|
||||
// Parse as local time since the filename timestamp is created in local time
|
||||
parsedTime, err := time.ParseInLocation("2006-01-02-15-04-05", timestampStr, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return parsedTime, nil
|
||||
}
|
||||
|
||||
func filterOldLogs(logs []logFileInfo, olderThanDays int) []logFileInfo {
|
||||
cutoff := time.Now().AddDate(0, 0, -olderThanDays)
|
||||
var filtered []logFileInfo
|
||||
|
||||
for _, log := range logs {
|
||||
if log.created.Before(cutoff) {
|
||||
filtered = append(filtered, log)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func deleteLogFiles(files []logFileInfo) (int, int64, error) {
|
||||
var count int
|
||||
var bytesFreed int64
|
||||
|
||||
for _, file := range files {
|
||||
if err := os.Remove(file.path); err != nil {
|
||||
return count, bytesFreed, err
|
||||
}
|
||||
count++
|
||||
bytesFreed += file.size
|
||||
}
|
||||
|
||||
return count, bytesFreed, nil
|
||||
}
|
||||
|
||||
func formatFileSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatAge(t time.Time) string {
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Hour {
|
||||
minutes := int(duration.Minutes())
|
||||
return fmt.Sprintf("%dm ago", minutes)
|
||||
}
|
||||
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
return fmt.Sprintf("%dh ago", hours)
|
||||
}
|
||||
|
||||
if duration < 7*24*time.Hour {
|
||||
days := int(duration.Hours() / 24)
|
||||
return fmt.Sprintf("%dd ago", days)
|
||||
}
|
||||
|
||||
weeks := int(duration.Hours() / 24 / 7)
|
||||
return fmt.Sprintf("%dw ago", weeks)
|
||||
}
|
||||
|
||||
func renderLogsTable(logs []logFileInfo, markForDeletion bool) error {
|
||||
// Build table data
|
||||
type tableRow struct {
|
||||
filename string
|
||||
size string
|
||||
created string
|
||||
age string
|
||||
}
|
||||
|
||||
var rows []tableRow
|
||||
for _, log := range logs {
|
||||
rows = append(rows, tableRow{
|
||||
filename: log.name,
|
||||
size: formatFileSize(log.size),
|
||||
created: log.created.Format("2006-01-02 15:04:05"),
|
||||
age: formatAge(log.created),
|
||||
})
|
||||
}
|
||||
|
||||
// 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, "FILENAME\tSIZE\tCREATED\tAGE")
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
row.filename,
|
||||
row.size,
|
||||
row.created,
|
||||
row.age,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use markdown table for rich output
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **FILENAME** | **SIZE** | **CREATED** | **AGE** |\n")
|
||||
markdown.WriteString("|--------------|----------|-------------|---------|")
|
||||
|
||||
for _, row := range rows {
|
||||
line := fmt.Sprintf("\n| %s | %s | %s | %s |",
|
||||
row.filename,
|
||||
row.size,
|
||||
row.created,
|
||||
row.age,
|
||||
)
|
||||
|
||||
// If marking for deletion, wrap in red ANSI codes
|
||||
if markForDeletion {
|
||||
line = "\033[31m" + line + "\033[0m"
|
||||
}
|
||||
|
||||
markdown.WriteString(line)
|
||||
}
|
||||
|
||||
// Render the markdown table
|
||||
renderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain markdown if renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// normalizeAddressVariants returns address variants to try when querying SQLite.
|
||||
// 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
|
||||
}
|
||||
|
||||
// LockManager provides access to the SQLite locks database
|
||||
type LockManager struct {
|
||||
dbPath string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewLockManager creates a new lock manager
|
||||
func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
|
||||
|
||||
// Ensure the directory exists (for future DB creation by cline-core)
|
||||
dbDir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
// Database doesn't exist - return manager with nil db
|
||||
// All methods already handle this gracefully!
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
// If connection fails, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
}
|
||||
|
||||
return &LockManager{
|
||||
dbPath: dbPath,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureConnection attempts to establish a database connection if one doesn't exist
|
||||
func (lm *LockManager) ensureConnection() error {
|
||||
// If we already have a connection, we're done
|
||||
if lm.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if database exists now (created by cline-core)
|
||||
if _, err := os.Stat(lm.dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("database not available")
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite3", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("database connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Success! Update our connection permanently
|
||||
lm.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (lm *LockManager) Close() error {
|
||||
if lm.db != nil {
|
||||
return lm.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceLocks returns all instance locks
|
||||
func (lm *LockManager) GetInstanceLocks() ([]common.LockRow, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []common.LockRow{}, nil
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLocksSQL
|
||||
|
||||
rows, err := lm.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query instance locks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var locks []common.LockRow
|
||||
for rows.Next() {
|
||||
var lock common.LockRow
|
||||
err := rows.Scan(&lock.ID, &lock.HeldBy, &lock.LockType, &lock.LockTarget, &lock.LockedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan lock row: %w", err)
|
||||
}
|
||||
locks = append(locks, lock)
|
||||
}
|
||||
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// RemoveInstanceLock removes an instance lock by address
|
||||
func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil // Gracefully handle missing database for cleanup operations
|
||||
}
|
||||
|
||||
query := common.DeleteInstanceLockSQL
|
||||
_, err := lm.db.Exec(query, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove instance lock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
var count int
|
||||
err := lm.db.QueryRow(query, address).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check instance existence: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetInstanceInfo returns instance information directly from SQLite.
|
||||
// Handles localhost/127.0.0.1 equivalence by trying both variants.
|
||||
func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
|
||||
// Try each address variant (e.g., localhost:50607 and 127.0.0.1:50607)
|
||||
for _, variant := range variants {
|
||||
err := lm.db.QueryRow(query, variant).Scan(&heldBy, &lockTarget, &lockedAt)
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
}, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
// Real error (not just "not found"), save it
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("instance %s not found", address)
|
||||
}
|
||||
|
||||
// ListInstancesWithHealthCheck returns all instances with real-time health checks
|
||||
func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return []*common.CoreInstanceInfo{}, nil
|
||||
}
|
||||
|
||||
// Get all instance locks
|
||||
locks, err := lm.GetInstanceLocks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance locks: %w", err)
|
||||
}
|
||||
|
||||
var instances []*common.CoreInstanceInfo
|
||||
|
||||
for _, lock := range locks {
|
||||
// Create instance info using actual SQLite data
|
||||
status, err := common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
if status != grpc_health_v1.HealthCheckResponse_SERVING || err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = common.PerformHealthCheck(ctx, lock.HeldBy)
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
Address: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
}
|
||||
|
||||
instances = append(instances, info)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetDefaultInstance reads the default instance from the settings file
|
||||
func GetDefaultInstance(clineDir string) (string, error) {
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to read default instance file: %w", err)
|
||||
}
|
||||
|
||||
var defaultInstance common.DefaultCoreInstance
|
||||
if err := json.Unmarshal(data, &defaultInstance); err != nil {
|
||||
return "", fmt.Errorf("failed to parse default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if defaultInstance.Address == "" {
|
||||
return "", fmt.Errorf("default instance not set in settings file")
|
||||
}
|
||||
|
||||
return defaultInstance.Address, nil
|
||||
}
|
||||
|
||||
// SetDefaultInstance writes the default instance to the settings file with proper locking
|
||||
func SetDefaultInstance(clineDir, address string) error {
|
||||
// Create lock manager for this operation
|
||||
lockManager, err := NewLockManager(clineDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Warning: SQLite unavailable, writing without lock: %v\n", err)
|
||||
}
|
||||
defer lockManager.Close()
|
||||
|
||||
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
|
||||
// Generate a unique identifier for this CLI process
|
||||
heldBy := fmt.Sprintf("cli-process-%d", os.Getpid())
|
||||
|
||||
// Use file lock for the write operation
|
||||
return lockManager.WithFileLock(settingsPath, heldBy, func() error {
|
||||
return writeDefaultInstanceJSONToDisk(clineDir, address)
|
||||
})
|
||||
}
|
||||
|
||||
func writeDefaultInstanceJSONToDisk(clineDir, address string) error {
|
||||
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings")
|
||||
if err := os.MkdirAll(settingsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
settingsPath := filepath.Join(settingsDir, "cli-default-instance.json")
|
||||
|
||||
payload := common.DefaultCoreInstance{
|
||||
Address: address,
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal default instance JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write default instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcquireFileLock attempts to acquire a file lock
|
||||
func (lm *LockManager) AcquireFileLock(filePath, heldBy string) error {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix() * 1000 // Convert to milliseconds
|
||||
|
||||
query := common.InsertFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseFileLock releases a file lock
|
||||
func (lm *LockManager) ReleaseFileLock(filePath, heldBy string) error {
|
||||
if lm.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := common.DeleteFileLockSQL
|
||||
|
||||
_, err := lm.db.Exec(query, heldBy, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release file lock for %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFileLock executes a function while holding a file lock
|
||||
func (lm *LockManager) WithFileLock(filePath, heldBy string, fn func() error) error {
|
||||
if err := lm.AcquireFileLock(filePath, heldBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if releaseErr := lm.ReleaseFileLock(filePath, heldBy); releaseErr != nil {
|
||||
fmt.Printf("Warning: Failed to release file lock for %s: %v\n", filePath, releaseErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TaskOptions contains options for creating a task
|
||||
type TaskOptions struct {
|
||||
Images []string
|
||||
Files []string
|
||||
Workspaces []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"t"},
|
||||
Short: "Manage Cline tasks",
|
||||
Long: `Create, monitor, and manage Cline AI tasks.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskOneshotCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(NewTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
cmd.AddCommand(newTaskRestoreCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var taskManager *task.Manager
|
||||
|
||||
func ensureTaskManager(ctx context.Context, address string) error {
|
||||
if taskManager == nil || (address != "" && taskManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// 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 {
|
||||
// Ensure default instance exists
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
if err == nil {
|
||||
instanceAddress = taskManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new <prompt>",
|
||||
Aliases: []string{"n"},
|
||||
Short: "Create a new task",
|
||||
Long: `Create a new Cline task with the specified prompt. If no Cline instance exists at the specified address, a new one will be started automatically.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// Validate that prompt is passed in call
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if mode != "" {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
|
||||
// Will append to the -s settings to be parsed by the settings parser logic.
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s aws-region=us-west-2 -s mode=act)")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskOneshotCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
address string
|
||||
settings []string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "oneshot <prompt>",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Create a task in yolo+plan mode and view until completion",
|
||||
Long: `Creates a new task in yolo mode (non-interactive) and plan mode, then streams the conversation until completion.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get prompt from args/stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode to plan
|
||||
if err := taskManager.SetMode(ctx, "plan", nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set plan mode: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Mode set to: plan")
|
||||
}
|
||||
|
||||
// Inject yolo mode into settings
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
|
||||
// Create task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created in yolo+plan mode (ID: %s)\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Follow until completion
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s model=claude)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Cancel the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := taskManager.CancelTask(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task cancelled successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send [message]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Send a followup message to the current task and/or update mode/approve",
|
||||
Long: `Send a followup message to continue the conversation with the current task and/or update mode/approve.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get content from both args and stdin
|
||||
message, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && approve == "" {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode or --approve flags")
|
||||
}
|
||||
|
||||
if approve != "" && approve != "true" && approve != "false" {
|
||||
return fmt.Errorf("--approve must be 'true' or 'false'")
|
||||
}
|
||||
|
||||
if approve != "" && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sendDisabled, err := taskManager.CheckSendDisabled(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if message can be sent: %w", err)
|
||||
}
|
||||
|
||||
if sendDisabled {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
if err := taskManager.SetModeAndSendMessage(ctx, mode, message, images, files); err != nil {
|
||||
return fmt.Errorf("failed to set mode and send message: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
}
|
||||
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringVarP(&approve, "approve", "a", "", "approve (true) or deny (false) pending request")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskFollowCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "follow",
|
||||
Aliases: []string{"f"},
|
||||
Short: "Follow current task conversation in real-time",
|
||||
Long: `Follow the current task conversation, displaying new messages as they arrive in real-time. Interactive input is enabled by default.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
current bool
|
||||
summary bool
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "view",
|
||||
Aliases: []string{"v"},
|
||||
Short: "View task conversation",
|
||||
Long: `Output conversation until next completion, with options for current state or summary only.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if current {
|
||||
return taskManager.ShowConversation(ctx)
|
||||
} else if summary {
|
||||
return taskManager.GatherFinalSummary(ctx)
|
||||
} else {
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(¤t, "current", "c", false, "output current conversation without following")
|
||||
cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List recent task history",
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ListTasks(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskResumeCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "resume <task-id>",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Resume a task by ID",
|
||||
Long: `Resume an existing task by ID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
taskID := args[0]
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ResumeTask(ctx, taskID)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskRestoreCommand() *cobra.Command {
|
||||
var (
|
||||
restoreType string
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <checkpoint-id>",
|
||||
Short: "Restore task to a specific checkpoint",
|
||||
Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
checkpointID := args[0]
|
||||
|
||||
// Convert checkpoint ID string to int64
|
||||
id, err := strconv.ParseInt(checkpointID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID)
|
||||
}
|
||||
|
||||
validTypes := []string{"task", "workspace", "taskAndWorkspace"}
|
||||
if !slices.Contains(validTypes, restoreType) {
|
||||
return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType)
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate checkpoint exists before attempting restore
|
||||
if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType)
|
||||
|
||||
if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Checkpoint restored successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
|
||||
// Add command line args first (if any)
|
||||
if len(args) > 0 {
|
||||
content.WriteString(strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Check if stdin has data
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat stdin: %w", err)
|
||||
}
|
||||
|
||||
// Check if data is being piped to stdin
|
||||
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
||||
stdinBytes, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read from stdin: %w", err)
|
||||
}
|
||||
|
||||
stdinContent := strings.TrimSpace(string(stdinBytes))
|
||||
if stdinContent != "" {
|
||||
if content.Len() > 0 {
|
||||
content.WriteString(" ")
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
|
||||
// CleanupTaskManager cleans up the task manager resources
|
||||
func CleanupTaskManager() {
|
||||
if taskManager != nil {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskManagerForAddress is an exported wrapper around task.NewManagerForAddress
|
||||
func NewTaskManagerForAddress(ctx context.Context, address string) (*task.Manager, error) {
|
||||
return task.NewManagerForAddress(ctx, address)
|
||||
}
|
||||
|
||||
// CreateAndFollowTask creates a new task and immediately follows it in interactive mode
|
||||
// This is used by the root command to provide a streamlined UX
|
||||
func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) error {
|
||||
// Initialize task manager with the provided instance address
|
||||
if err := ensureTaskManager(ctx, opts.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode to plan by default if not specified
|
||||
if opts.Mode == "" {
|
||||
opts.Mode = "plan"
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if opts.Mode != "" {
|
||||
if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", opts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
if opts.Yolo {
|
||||
opts.Settings = append(opts.Settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Workspaces, opts.Settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
|
||||
}
|
||||
|
||||
// Immediately follow the conversation in interactive mode
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// InputHandler manages interactive user input during follow mode
|
||||
type InputHandler struct {
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins monitoring for input opportunities
|
||||
func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = false
|
||||
ih.mu.Unlock()
|
||||
ih.pollTicker.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ih.pollTicker.C:
|
||||
// First check if approval is needed
|
||||
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
// Send approval response
|
||||
approveStr := "false"
|
||||
if approved {
|
||||
approveStr = "true"
|
||||
}
|
||||
|
||||
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
|
||||
fmt.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we can send a regular message
|
||||
sendDisabled, err := ih.manager.CheckSendDisabled(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckSendDisabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If send is enabled (not disabled), show prompt
|
||||
if !sendDisabled {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
if shouldSend {
|
||||
// Check for mode switch commands first
|
||||
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
|
||||
if isModeSwitch {
|
||||
// Switch mode
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
fmt.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\nSwitched to %s mode\n", newMode)
|
||||
|
||||
// If there's remaining message, use it as the new message to send
|
||||
if remainingMessage != "" {
|
||||
message = remainingMessage
|
||||
} else {
|
||||
// No message to send, just mode switch
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
if handled := ih.handleSpecialCommand(ctx, message); handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the message
|
||||
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
|
||||
fmt.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
} else {
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// promptForInput displays an interactive prompt and waits for user input
|
||||
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
var message string
|
||||
|
||||
// Get current mode and format title with color
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
// ANSI color codes
|
||||
yellow := "\033[33m" // Yellow for plan mode
|
||||
blue := "\033[34m" // Blue for act mode
|
||||
indigo := "\033[38;5;99m" // Indigo (huh default title color) - approximation of #7571F9
|
||||
bold := "\033[1m" // Bold
|
||||
reset := "\033[0m" // Reset
|
||||
|
||||
var coloredMode string
|
||||
if currentMode == "plan" {
|
||||
coloredMode = fmt.Sprintf("%s[plan mode]%s", yellow, reset)
|
||||
} else {
|
||||
coloredMode = fmt.Sprintf("%s[act mode]%s", blue, reset)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%s %s%sCline is ready for your message%s", coloredMode, bold, indigo, reset)
|
||||
|
||||
// Create multiline text area form using huh
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title(title).
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&message),
|
||||
),
|
||||
)
|
||||
|
||||
// Run the form
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
message = strings.TrimSpace(message)
|
||||
|
||||
// If empty, user just wants to keep watching
|
||||
if message == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return message, true, nil
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
// Returns (approved, message, error)
|
||||
// Note: The approval details are already shown by segment streamer / state stream
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
// Show selection menu (approval details already displayed by other handlers)
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Let Cline use this tool?").
|
||||
Options(
|
||||
huh.NewOption("Yes", "yes"),
|
||||
huh.NewOption("Yes, with feedback", "yes_feedback"),
|
||||
huh.NewOption("No", "no"),
|
||||
huh.NewOption("No, with feedback", "no_feedback"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
// Check if feedback is needed
|
||||
needsFeedback := choice == "yes_feedback" || choice == "no_feedback"
|
||||
approved := choice == "yes" || choice == "yes_feedback"
|
||||
|
||||
var feedback string
|
||||
if needsFeedback {
|
||||
// Show multiline text area for feedback
|
||||
feedbackForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Your feedback").
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&feedback),
|
||||
),
|
||||
)
|
||||
|
||||
err := feedbackForm.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
feedback = strings.TrimSpace(feedback)
|
||||
}
|
||||
|
||||
return approved, feedback, nil
|
||||
}
|
||||
|
||||
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
|
||||
// Returns: (newMode, remainingMessage, isModeSwitch)
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
// Extract remaining message after /plan
|
||||
remaining := strings.TrimSpace(trimmed[5:]) // Remove "/plan"
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
// Extract remaining message after /act
|
||||
remaining := strings.TrimSpace(trimmed[4:]) // Remove "/act"
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
return "", message, false
|
||||
}
|
||||
|
||||
// handleSpecialCommand processes special commands like /cancel, /exit
|
||||
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(message)) {
|
||||
case "/cancel":
|
||||
ih.manager.GetRenderer().RenderTaskCancelled()
|
||||
if err := ih.manager.CancelTask(ctx); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
fmt.Println("\nExiting follow mode...")
|
||||
// This will be handled by context cancellation
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the input handler
|
||||
func (ih *InputHandler) Stop() {
|
||||
ih.mu.Lock()
|
||||
defer ih.mu.Unlock()
|
||||
if ih.pollTicker != nil {
|
||||
ih.pollTicker.Stop()
|
||||
}
|
||||
ih.isRunning = false
|
||||
}
|
||||
|
||||
// IsRunning returns whether the input handler is currently running
|
||||
func (ih *InputHandler) IsRunning() bool {
|
||||
ih.mu.RLock()
|
||||
defer ih.mu.RUnlock()
|
||||
return ih.isRunning
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,762 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
|
||||
func ParseTaskSettings(settingsFlags []string) (*cline.Settings, *cline.Secrets, error) {
|
||||
if len(settingsFlags) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
settings := &cline.Settings{}
|
||||
secrets := &cline.Secrets{}
|
||||
nestedSettings := make(map[string]map[string]string)
|
||||
|
||||
for _, flag := range settingsFlags {
|
||||
// Parse key=value
|
||||
parts := strings.SplitN(flag, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, fmt.Errorf("invalid setting format '%s': expected key=value", flag)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Convert kebab-case to snake_case
|
||||
key = kebabToSnake(key)
|
||||
|
||||
// Check if this is a nested setting (contains a dot)
|
||||
if strings.Contains(key, ".") {
|
||||
dotParts := strings.SplitN(key, ".", 2)
|
||||
parentField := dotParts[0]
|
||||
childField := dotParts[1]
|
||||
|
||||
if nestedSettings[parentField] == nil {
|
||||
nestedSettings[parentField] = make(map[string]string)
|
||||
}
|
||||
nestedSettings[parentField][childField] = value
|
||||
} else {
|
||||
// Check if it's a secret field first, then settings field
|
||||
if err := setSecretField(secrets, key, value); err == nil {
|
||||
// Successfully set as secret, continue
|
||||
continue
|
||||
}
|
||||
// Not a secret, try as a settings field
|
||||
if err := setSimpleField(settings, key, value); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting field '%s': %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested settings
|
||||
for parentField, childFields := range nestedSettings {
|
||||
if err := setNestedField(settings, parentField, childFields); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting nested field '%s': %w", parentField, err)
|
||||
}
|
||||
}
|
||||
|
||||
return settings, secrets, nil
|
||||
}
|
||||
|
||||
// kebabToSnake converts kebab-case to snake_case
|
||||
func kebabToSnake(s string) string {
|
||||
return strings.ReplaceAll(s, "-", "_")
|
||||
}
|
||||
|
||||
// Pointer helper functions for optional protobuf fields
|
||||
func strPtr(s string) *string { return &s }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
func float64Ptr(f float64) *float64 { return &f }
|
||||
|
||||
// setSimpleField sets a simple (non-nested) field on Settings
|
||||
func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
switch key {
|
||||
// String fields
|
||||
case "aws_region":
|
||||
settings.AwsRegion = strPtr(value)
|
||||
case "aws_bedrock_endpoint":
|
||||
settings.AwsBedrockEndpoint = strPtr(value)
|
||||
case "aws_profile":
|
||||
settings.AwsProfile = strPtr(value)
|
||||
case "aws_authentication":
|
||||
settings.AwsAuthentication = strPtr(value)
|
||||
case "vertex_project_id":
|
||||
settings.VertexProjectId = strPtr(value)
|
||||
case "vertex_region":
|
||||
settings.VertexRegion = strPtr(value)
|
||||
case "requesty_base_url":
|
||||
settings.RequestyBaseUrl = strPtr(value)
|
||||
case "open_ai_base_url":
|
||||
settings.OpenAiBaseUrl = strPtr(value)
|
||||
case "ollama_base_url":
|
||||
settings.OllamaBaseUrl = strPtr(value)
|
||||
case "ollama_api_options_ctx_num":
|
||||
settings.OllamaApiOptionsCtxNum = strPtr(value)
|
||||
case "lm_studio_base_url":
|
||||
settings.LmStudioBaseUrl = strPtr(value)
|
||||
case "lm_studio_max_tokens":
|
||||
settings.LmStudioMaxTokens = strPtr(value)
|
||||
case "anthropic_base_url":
|
||||
settings.AnthropicBaseUrl = strPtr(value)
|
||||
case "gemini_base_url":
|
||||
settings.GeminiBaseUrl = strPtr(value)
|
||||
case "azure_api_version":
|
||||
settings.AzureApiVersion = strPtr(value)
|
||||
case "open_router_provider_sorting":
|
||||
settings.OpenRouterProviderSorting = strPtr(value)
|
||||
case "lite_llm_base_url":
|
||||
settings.LiteLlmBaseUrl = strPtr(value)
|
||||
case "qwen_api_line":
|
||||
settings.QwenApiLine = strPtr(value)
|
||||
case "moonshot_api_line":
|
||||
settings.MoonshotApiLine = strPtr(value)
|
||||
case "zai_api_line":
|
||||
settings.ZaiApiLine = strPtr(value)
|
||||
case "telemetry_setting":
|
||||
settings.TelemetrySetting = strPtr(value)
|
||||
case "asksage_api_url":
|
||||
settings.AsksageApiUrl = strPtr(value)
|
||||
case "default_terminal_profile":
|
||||
settings.DefaultTerminalProfile = strPtr(value)
|
||||
case "sap_ai_core_token_url":
|
||||
settings.SapAiCoreTokenUrl = strPtr(value)
|
||||
case "sap_ai_core_base_url":
|
||||
settings.SapAiCoreBaseUrl = strPtr(value)
|
||||
case "sap_ai_resource_group":
|
||||
settings.SapAiResourceGroup = strPtr(value)
|
||||
case "claude_code_path":
|
||||
settings.ClaudeCodePath = strPtr(value)
|
||||
case "qwen_code_oauth_path":
|
||||
settings.QwenCodeOauthPath = strPtr(value)
|
||||
case "preferred_language":
|
||||
settings.PreferredLanguage = strPtr(value)
|
||||
case "custom_prompt":
|
||||
settings.CustomPrompt = strPtr(value)
|
||||
case "dify_base_url":
|
||||
settings.DifyBaseUrl = strPtr(value)
|
||||
case "oca_base_url":
|
||||
settings.OcaBaseUrl = strPtr(value)
|
||||
case "plan_mode_api_model_id":
|
||||
settings.PlanModeApiModelId = strPtr(value)
|
||||
case "plan_mode_reasoning_effort":
|
||||
settings.PlanModeReasoningEffort = strPtr(value)
|
||||
case "plan_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.PlanModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "plan_mode_open_router_model_id":
|
||||
settings.PlanModeOpenRouterModelId = strPtr(value)
|
||||
case "plan_mode_open_ai_model_id":
|
||||
settings.PlanModeOpenAiModelId = strPtr(value)
|
||||
case "plan_mode_ollama_model_id":
|
||||
settings.PlanModeOllamaModelId = strPtr(value)
|
||||
case "plan_mode_lm_studio_model_id":
|
||||
settings.PlanModeLmStudioModelId = strPtr(value)
|
||||
case "plan_mode_lite_llm_model_id":
|
||||
settings.PlanModeLiteLlmModelId = strPtr(value)
|
||||
case "plan_mode_requesty_model_id":
|
||||
settings.PlanModeRequestyModelId = strPtr(value)
|
||||
case "plan_mode_together_model_id":
|
||||
settings.PlanModeTogetherModelId = strPtr(value)
|
||||
case "plan_mode_fireworks_model_id":
|
||||
settings.PlanModeFireworksModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_model_id":
|
||||
settings.PlanModeSapAiCoreModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_deployment_id":
|
||||
settings.PlanModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "plan_mode_groq_model_id":
|
||||
settings.PlanModeGroqModelId = strPtr(value)
|
||||
case "plan_mode_baseten_model_id":
|
||||
settings.PlanModeBasetenModelId = strPtr(value)
|
||||
case "plan_mode_hugging_face_model_id":
|
||||
settings.PlanModeHuggingFaceModelId = strPtr(value)
|
||||
case "plan_mode_huawei_cloud_maas_model_id":
|
||||
settings.PlanModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "plan_mode_oca_model_id":
|
||||
settings.PlanModeOcaModelId = strPtr(value)
|
||||
case "plan_mode_vercel_ai_gateway_model_id":
|
||||
settings.PlanModeVercelAiGatewayModelId = strPtr(value)
|
||||
case "act_mode_api_model_id":
|
||||
settings.ActModeApiModelId = strPtr(value)
|
||||
case "act_mode_reasoning_effort":
|
||||
settings.ActModeReasoningEffort = strPtr(value)
|
||||
case "act_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.ActModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "act_mode_open_router_model_id":
|
||||
settings.ActModeOpenRouterModelId = strPtr(value)
|
||||
case "act_mode_open_ai_model_id":
|
||||
settings.ActModeOpenAiModelId = strPtr(value)
|
||||
case "act_mode_ollama_model_id":
|
||||
settings.ActModeOllamaModelId = strPtr(value)
|
||||
case "act_mode_lm_studio_model_id":
|
||||
settings.ActModeLmStudioModelId = strPtr(value)
|
||||
case "act_mode_lite_llm_model_id":
|
||||
settings.ActModeLiteLlmModelId = strPtr(value)
|
||||
case "act_mode_requesty_model_id":
|
||||
settings.ActModeRequestyModelId = strPtr(value)
|
||||
case "act_mode_together_model_id":
|
||||
settings.ActModeTogetherModelId = strPtr(value)
|
||||
case "act_mode_fireworks_model_id":
|
||||
settings.ActModeFireworksModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_model_id":
|
||||
settings.ActModeSapAiCoreModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_deployment_id":
|
||||
settings.ActModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "act_mode_groq_model_id":
|
||||
settings.ActModeGroqModelId = strPtr(value)
|
||||
case "act_mode_baseten_model_id":
|
||||
settings.ActModeBasetenModelId = strPtr(value)
|
||||
case "act_mode_hugging_face_model_id":
|
||||
settings.ActModeHuggingFaceModelId = strPtr(value)
|
||||
case "act_mode_huawei_cloud_maas_model_id":
|
||||
settings.ActModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "act_mode_oca_model_id":
|
||||
settings.ActModeOcaModelId = strPtr(value)
|
||||
case "act_mode_vercel_ai_gateway_model_id":
|
||||
settings.ActModeVercelAiGatewayModelId = strPtr(value)
|
||||
|
||||
// Boolean fields
|
||||
case "aws_use_cross_region_inference":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseCrossRegionInference = boolPtr(val)
|
||||
case "aws_bedrock_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsBedrockUsePromptCache = boolPtr(val)
|
||||
case "aws_use_profile":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseProfile = boolPtr(val)
|
||||
case "lite_llm_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.LiteLlmUsePromptCache = boolPtr(val)
|
||||
case "plan_act_separate_models_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanActSeparateModelsSetting = boolPtr(val)
|
||||
case "enable_checkpoints_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableCheckpointsSetting = boolPtr(val)
|
||||
case "sap_ai_core_use_orchestration_mode":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.SapAiCoreUseOrchestrationMode = boolPtr(val)
|
||||
case "strict_plan_mode_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.StrictPlanModeEnabled = boolPtr(val)
|
||||
case "yolo_mode_toggled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.YoloModeToggled = boolPtr(val)
|
||||
case "use_auto_condense":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.UseAutoCondense = boolPtr(val)
|
||||
case "plan_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "act_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
|
||||
// Integer fields
|
||||
case "request_timeout_ms":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RequestTimeoutMs = int32Ptr(val)
|
||||
case "shell_integration_timeout":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ShellIntegrationTimeout = int32Ptr(val)
|
||||
case "terminal_output_line_limit":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.TerminalOutputLineLimit = int32Ptr(val)
|
||||
case "fireworks_model_max_completion_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxCompletionTokens = int32Ptr(val)
|
||||
case "fireworks_model_max_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxTokens = int32Ptr(val)
|
||||
|
||||
// Int64 fields
|
||||
case "plan_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeThinkingBudgetTokens = int64Ptr(val)
|
||||
case "act_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeThinkingBudgetTokens = int64Ptr(val)
|
||||
|
||||
// Double fields
|
||||
case "auto_condense_threshold":
|
||||
val, err := parseFloat64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AutoCondenseThreshold = float64Ptr(val)
|
||||
|
||||
// Enum fields
|
||||
// Note: We can use &val directly for enums because the parser functions return a new local variable.
|
||||
// This is different from using &value (the loop variable), which would cause all fields to share
|
||||
// the same memory address.
|
||||
case "openai_reasoning_effort":
|
||||
val, err := parseOpenaiReasoningEffort(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.OpenaiReasoningEffort = &val
|
||||
case "mode":
|
||||
val, err := parsePlanActMode(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Mode = &val
|
||||
case "plan_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeApiProvider = &val
|
||||
case "act_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeApiProvider = &val
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setNestedField sets a nested field on Settings
|
||||
// Currently supports: auto_approval_settings, browser_settings
|
||||
func setNestedField(settings *cline.Settings, parentField string, childFields map[string]string) error {
|
||||
switch parentField {
|
||||
case "auto_approval_settings":
|
||||
if settings.AutoApprovalSettings == nil {
|
||||
settings.AutoApprovalSettings = &cline.AutoApprovalSettings{}
|
||||
}
|
||||
return setAutoApprovalSettings(settings.AutoApprovalSettings, childFields)
|
||||
|
||||
case "browser_settings":
|
||||
if settings.BrowserSettings == nil {
|
||||
settings.BrowserSettings = &cline.BrowserSettings{}
|
||||
}
|
||||
return setBrowserSettings(settings.BrowserSettings, childFields)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported nested field '%s' (complex nested types are not supported via -s flags)", parentField)
|
||||
}
|
||||
}
|
||||
|
||||
// setAutoApprovalSettings sets fields on AutoApprovalSettings
|
||||
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Enabled = val
|
||||
case "max_requests":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MaxRequests = val
|
||||
case "enable_notifications":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableNotifications = val
|
||||
case "actions":
|
||||
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
|
||||
default:
|
||||
// Check if this is an action field (actions.*)
|
||||
if strings.HasPrefix(key, "actions.") {
|
||||
actionField := strings.TrimPrefix(key, "actions.")
|
||||
if settings.Actions == nil {
|
||||
settings.Actions = &cline.AutoApprovalActions{}
|
||||
}
|
||||
if err := setAutoApprovalAction(settings.Actions, actionField, value); err != nil {
|
||||
return err
|
||||
}
|
||||
// Continue processing other fields
|
||||
} else {
|
||||
return fmt.Errorf("unsupported auto_approval_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAutoApprovalAction sets fields on AutoApprovalActions
|
||||
func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string) error {
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "read_files":
|
||||
actions.ReadFiles = val
|
||||
case "read_files_externally":
|
||||
actions.ReadFilesExternally = val
|
||||
case "edit_files":
|
||||
actions.EditFiles = val
|
||||
case "edit_files_externally":
|
||||
actions.EditFilesExternally = val
|
||||
case "execute_safe_commands":
|
||||
actions.ExecuteSafeCommands = val
|
||||
case "execute_all_commands":
|
||||
actions.ExecuteAllCommands = val
|
||||
case "use_browser":
|
||||
actions.UseBrowser = val
|
||||
case "use_mcp":
|
||||
actions.UseMcp = val
|
||||
default:
|
||||
return fmt.Errorf("unsupported auto_approval_actions field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setBrowserSettings sets fields on BrowserSettings
|
||||
func setBrowserSettings(settings *cline.BrowserSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "viewport_width":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Width = val
|
||||
case "viewport_height":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Height = val
|
||||
case "remote_browser_host":
|
||||
settings.RemoteBrowserHost = strPtr(value)
|
||||
case "remote_browser_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RemoteBrowserEnabled = boolPtr(val)
|
||||
case "chrome_executable_path":
|
||||
settings.ChromeExecutablePath = strPtr(value)
|
||||
case "disable_tool_use":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.DisableToolUse = boolPtr(val)
|
||||
case "custom_args":
|
||||
settings.CustomArgs = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported browser_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type parsing helpers
|
||||
func parseBool(value string) (bool, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "true", "t", "yes", "y", "1":
|
||||
return true, nil
|
||||
case "false", "f", "no", "n", "0":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value '%s': expected true/false", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt32(value string) (int32, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return int32(val), nil
|
||||
}
|
||||
|
||||
func parseInt64(value string) (int64, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseFloat64(value string) (float64, error) {
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid float value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Enum parsing helpers
|
||||
func parseOpenaiReasoningEffort(value string) (cline.OpenaiReasoningEffort, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "low":
|
||||
return cline.OpenaiReasoningEffort_LOW, nil
|
||||
case "medium":
|
||||
return cline.OpenaiReasoningEffort_MEDIUM, nil
|
||||
case "high":
|
||||
return cline.OpenaiReasoningEffort_HIGH, nil
|
||||
default:
|
||||
return cline.OpenaiReasoningEffort_LOW, fmt.Errorf("invalid openai_reasoning_effort '%s': expected low/medium/high", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlanActMode(value string) (cline.PlanActMode, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "plan":
|
||||
return cline.PlanActMode_PLAN, nil
|
||||
case "act":
|
||||
return cline.PlanActMode_ACT, nil
|
||||
default:
|
||||
return cline.PlanActMode_ACT, fmt.Errorf("invalid mode '%s': expected plan/act", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseApiProvider(value string) (cline.ApiProvider, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, nil
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, nil
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, nil
|
||||
case "vertex":
|
||||
return cline.ApiProvider_VERTEX, nil
|
||||
case "openai":
|
||||
return cline.ApiProvider_OPENAI, nil
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, nil
|
||||
case "lmstudio":
|
||||
return cline.ApiProvider_LMSTUDIO, nil
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, nil
|
||||
case "openai_native":
|
||||
return cline.ApiProvider_OPENAI_NATIVE, nil
|
||||
case "requesty":
|
||||
return cline.ApiProvider_REQUESTY, nil
|
||||
case "together":
|
||||
return cline.ApiProvider_TOGETHER, nil
|
||||
case "deepseek":
|
||||
return cline.ApiProvider_DEEPSEEK, nil
|
||||
case "qwen":
|
||||
return cline.ApiProvider_QWEN, nil
|
||||
case "doubao":
|
||||
return cline.ApiProvider_DOUBAO, nil
|
||||
case "mistral":
|
||||
return cline.ApiProvider_MISTRAL, nil
|
||||
case "vscode_lm":
|
||||
return cline.ApiProvider_VSCODE_LM, nil
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, nil
|
||||
case "litellm":
|
||||
return cline.ApiProvider_LITELLM, nil
|
||||
case "nebius":
|
||||
return cline.ApiProvider_NEBIUS, nil
|
||||
case "fireworks":
|
||||
return cline.ApiProvider_FIREWORKS, nil
|
||||
case "asksage":
|
||||
return cline.ApiProvider_ASKSAGE, nil
|
||||
case "xai", "grok":
|
||||
return cline.ApiProvider_XAI, nil
|
||||
case "sambanova":
|
||||
return cline.ApiProvider_SAMBANOVA, nil
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, nil
|
||||
case "groq":
|
||||
return cline.ApiProvider_GROQ, nil
|
||||
case "sapaicore", "sap_ai_core":
|
||||
return cline.ApiProvider_SAPAICORE, nil
|
||||
case "claude_code":
|
||||
return cline.ApiProvider_CLAUDE_CODE, nil
|
||||
case "moonshot":
|
||||
return cline.ApiProvider_MOONSHOT, nil
|
||||
case "huggingface":
|
||||
return cline.ApiProvider_HUGGINGFACE, nil
|
||||
case "huawei_cloud_maas":
|
||||
return cline.ApiProvider_HUAWEI_CLOUD_MAAS, nil
|
||||
case "baseten":
|
||||
return cline.ApiProvider_BASETEN, nil
|
||||
case "zai":
|
||||
return cline.ApiProvider_ZAI, nil
|
||||
case "vercel_ai_gateway":
|
||||
return cline.ApiProvider_VERCEL_AI_GATEWAY, nil
|
||||
case "qwen_code":
|
||||
return cline.ApiProvider_QWEN_CODE, nil
|
||||
case "dify":
|
||||
return cline.ApiProvider_DIFY, nil
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, nil
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value)
|
||||
}
|
||||
}
|
||||
|
||||
// setSecretField sets a secret field on Secrets
|
||||
// All secret fields are optional strings
|
||||
// Returns nil if field was successfully set, error otherwise
|
||||
func setSecretField(secrets *cline.Secrets, key, value string) error {
|
||||
switch key {
|
||||
case "api_key":
|
||||
secrets.ApiKey = strPtr(value)
|
||||
case "open_router_api_key":
|
||||
secrets.OpenRouterApiKey = strPtr(value)
|
||||
case "aws_access_key":
|
||||
secrets.AwsAccessKey = strPtr(value)
|
||||
case "aws_secret_key":
|
||||
secrets.AwsSecretKey = strPtr(value)
|
||||
case "aws_session_token":
|
||||
secrets.AwsSessionToken = strPtr(value)
|
||||
case "aws_bedrock_api_key":
|
||||
secrets.AwsBedrockApiKey = strPtr(value)
|
||||
case "open_ai_api_key":
|
||||
secrets.OpenAiApiKey = strPtr(value)
|
||||
case "gemini_api_key":
|
||||
secrets.GeminiApiKey = strPtr(value)
|
||||
case "open_ai_native_api_key":
|
||||
secrets.OpenAiNativeApiKey = strPtr(value)
|
||||
case "ollama_api_key":
|
||||
secrets.OllamaApiKey = strPtr(value)
|
||||
case "deep_seek_api_key":
|
||||
secrets.DeepSeekApiKey = strPtr(value)
|
||||
case "requesty_api_key":
|
||||
secrets.RequestyApiKey = strPtr(value)
|
||||
case "together_api_key":
|
||||
secrets.TogetherApiKey = strPtr(value)
|
||||
case "fireworks_api_key":
|
||||
secrets.FireworksApiKey = strPtr(value)
|
||||
case "qwen_api_key":
|
||||
secrets.QwenApiKey = strPtr(value)
|
||||
case "doubao_api_key":
|
||||
secrets.DoubaoApiKey = strPtr(value)
|
||||
case "mistral_api_key":
|
||||
secrets.MistralApiKey = strPtr(value)
|
||||
case "lite_llm_api_key":
|
||||
secrets.LiteLlmApiKey = strPtr(value)
|
||||
case "auth_nonce":
|
||||
secrets.AuthNonce = strPtr(value)
|
||||
case "asksage_api_key":
|
||||
secrets.AsksageApiKey = strPtr(value)
|
||||
case "xai_api_key":
|
||||
secrets.XaiApiKey = strPtr(value)
|
||||
case "moonshot_api_key":
|
||||
secrets.MoonshotApiKey = strPtr(value)
|
||||
case "zai_api_key":
|
||||
secrets.ZaiApiKey = strPtr(value)
|
||||
case "hugging_face_api_key":
|
||||
secrets.HuggingFaceApiKey = strPtr(value)
|
||||
case "nebius_api_key":
|
||||
secrets.NebiusApiKey = strPtr(value)
|
||||
case "sambanova_api_key":
|
||||
secrets.SambanovaApiKey = strPtr(value)
|
||||
case "cerebras_api_key":
|
||||
secrets.CerebrasApiKey = strPtr(value)
|
||||
case "sap_ai_core_client_id":
|
||||
secrets.SapAiCoreClientId = strPtr(value)
|
||||
case "sap_ai_core_client_secret":
|
||||
secrets.SapAiCoreClientSecret = strPtr(value)
|
||||
case "groq_api_key":
|
||||
secrets.GroqApiKey = strPtr(value)
|
||||
case "huawei_cloud_maas_api_key":
|
||||
secrets.HuaweiCloudMaasApiKey = strPtr(value)
|
||||
case "baseten_api_key":
|
||||
secrets.BasetenApiKey = strPtr(value)
|
||||
case "vercel_ai_gateway_api_key":
|
||||
secrets.VercelAiGatewayApiKey = strPtr(value)
|
||||
case "dify_api_key":
|
||||
secrets.DifyApiKey = strPtr(value)
|
||||
case "oca_api_key":
|
||||
secrets.OcaApiKey = strPtr(value)
|
||||
case "oca_refresh_token":
|
||||
secrets.OcaRefreshToken = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported secret field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note: message types not supported via -s flags:
|
||||
// - OpenRouterModelInfo, OpenAiCompatibleModelInfo, LiteLLMModelInfo, OcaModelInfo
|
||||
// - LanguageModelChatSelector
|
||||
// - DictationSettings
|
||||
// - FocusChainSettings
|
||||
@@ -0,0 +1,81 @@
|
||||
package task
|
||||
|
||||
import "sync"
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
inputAllowed bool // Whether user input is currently allowed
|
||||
mu sync.RWMutex // Protects inputAllowed
|
||||
outputMu sync.Mutex // Protects terminal output (prevents interleaving with input forms)
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
func NewStreamCoordinator() *StreamCoordinator {
|
||||
return &StreamCoordinator{
|
||||
conversationTurnStartIndex: 0,
|
||||
processedInCurrentTurn: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationTurnStartIndex sets the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) SetConversationTurnStartIndex(index int) {
|
||||
sc.conversationTurnStartIndex = index
|
||||
}
|
||||
|
||||
// GetConversationTurnStartIndex returns the starting index for the current conversation turn
|
||||
func (sc *StreamCoordinator) GetConversationTurnStartIndex() int {
|
||||
return sc.conversationTurnStartIndex
|
||||
}
|
||||
|
||||
// MarkProcessedInCurrentTurn marks an item as processed in the current turn
|
||||
func (sc *StreamCoordinator) MarkProcessedInCurrentTurn(key string) {
|
||||
sc.processedInCurrentTurn[key] = true
|
||||
}
|
||||
|
||||
// IsProcessedInCurrentTurn checks if an item has been processed in the current turn
|
||||
func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn updates the start index for the next batch of messages
|
||||
// Note: Does NOT reset the processed map - that persists across state updates
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
// Don't reset processedInCurrentTurn - it should persist across state updates
|
||||
}
|
||||
|
||||
// SetInputAllowed sets whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) SetInputAllowed(allowed bool) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.inputAllowed = allowed
|
||||
}
|
||||
|
||||
// IsInputAllowed returns whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) IsInputAllowed() bool {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.inputAllowed
|
||||
}
|
||||
|
||||
// LockOutput locks the output mutex to prevent interleaved terminal output
|
||||
// Should be called before displaying input forms
|
||||
func (sc *StreamCoordinator) LockOutput() {
|
||||
sc.outputMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockOutput unlocks the output mutex
|
||||
// Should be called after input forms are dismissed
|
||||
func (sc *StreamCoordinator) UnlockOutput() {
|
||||
sc.outputMu.Unlock()
|
||||
}
|
||||
|
||||
// WithOutputLock executes a function while holding the output lock
|
||||
// This is a convenience method for wrapping output operations
|
||||
func (sc *StreamCoordinator) WithOutputLock(fn func()) {
|
||||
sc.outputMu.Lock()
|
||||
defer sc.outputMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
if you can make a beautiful tui in go, please help!
|
||||
@@ -0,0 +1,331 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
LastCheckpointHash string `json:"lastCheckpointHash,omitempty"`
|
||||
IsCheckpointCheckedOut bool `json:"isCheckpointCheckedOut,omitempty"`
|
||||
IsOperationOutsideWorkspace bool `json:"isOperationOutsideWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
MessageTypeAsk MessageType = "ask"
|
||||
MessageTypeSay MessageType = "say"
|
||||
)
|
||||
|
||||
// AskType represents different types of ASK messages
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
AskTypeCondense AskType = "condense"
|
||||
AskTypeReportBug AskType = "report_bug"
|
||||
)
|
||||
|
||||
// SayType represents different types of SAY messages
|
||||
type SayType string
|
||||
|
||||
const (
|
||||
SayTypeTask SayType = "task"
|
||||
SayTypeError SayType = "error"
|
||||
SayTypeAPIReqStarted SayType = "api_req_started"
|
||||
SayTypeAPIReqFinished SayType = "api_req_finished"
|
||||
SayTypeText SayType = "text"
|
||||
SayTypeReasoning SayType = "reasoning"
|
||||
SayTypeCompletionResult SayType = "completion_result"
|
||||
SayTypeUserFeedback SayType = "user_feedback"
|
||||
SayTypeUserFeedbackDiff SayType = "user_feedback_diff"
|
||||
SayTypeAPIReqRetried SayType = "api_req_retried"
|
||||
SayTypeErrorRetry SayType = "error_retry"
|
||||
SayTypeCommand SayType = "command"
|
||||
SayTypeCommandOutput SayType = "command_output"
|
||||
SayTypeTool SayType = "tool"
|
||||
SayTypeShellIntegrationWarning SayType = "shell_integration_warning"
|
||||
SayTypeBrowserActionLaunch SayType = "browser_action_launch"
|
||||
SayTypeBrowserAction SayType = "browser_action"
|
||||
SayTypeBrowserActionResult SayType = "browser_action_result"
|
||||
SayTypeMcpServerRequestStarted SayType = "mcp_server_request_started"
|
||||
SayTypeMcpServerResponse SayType = "mcp_server_response"
|
||||
SayTypeMcpNotification SayType = "mcp_notification"
|
||||
SayTypeUseMcpServer SayType = "use_mcp_server"
|
||||
SayTypeDiffError SayType = "diff_error"
|
||||
SayTypeDeletedAPIReqs SayType = "deleted_api_reqs"
|
||||
SayTypeClineignoreError SayType = "clineignore_error"
|
||||
SayTypeCheckpointCreated SayType = "checkpoint_created"
|
||||
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
|
||||
SayTypeInfo SayType = "info"
|
||||
SayTypeTaskProgress SayType = "task_progress"
|
||||
)
|
||||
|
||||
// ToolMessage represents a tool-related message
|
||||
type ToolMessage struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
Regex string `json:"regex,omitempty"`
|
||||
FilePattern string `json:"filePattern,omitempty"`
|
||||
OperationIsLocatedInWorkspace *bool `json:"operationIsLocatedInWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// ToolType represents different types of tools
|
||||
type ToolType string
|
||||
|
||||
const (
|
||||
ToolTypeEditedExistingFile ToolType = "editedExistingFile"
|
||||
ToolTypeNewFileCreated ToolType = "newFileCreated"
|
||||
ToolTypeReadFile ToolType = "readFile"
|
||||
ToolTypeListFilesTopLevel ToolType = "listFilesTopLevel"
|
||||
ToolTypeListFilesRecursive ToolType = "listFilesRecursive"
|
||||
ToolTypeListCodeDefinitionNames ToolType = "listCodeDefinitionNames"
|
||||
ToolTypeSearchFiles ToolType = "searchFiles"
|
||||
ToolTypeWebFetch ToolType = "webFetch"
|
||||
ToolTypeSummarizeTask ToolType = "summarizeTask"
|
||||
)
|
||||
|
||||
// AskData represents the parsed structure of an ASK message
|
||||
type AskData struct {
|
||||
Question string `json:"question"`
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestInfo represents API request information
|
||||
type APIRequestInfo struct {
|
||||
Request string `json:"request,omitempty"`
|
||||
TokensIn int `json:"tokensIn,omitempty"`
|
||||
TokensOut int `json:"tokensOut,omitempty"`
|
||||
CacheWrites int `json:"cacheWrites,omitempty"`
|
||||
CacheReads int `json:"cacheReads,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
CancelReason string `json:"cancelReason,omitempty"`
|
||||
StreamingFailedMessage string `json:"streamingFailedMessage,omitempty"`
|
||||
RetryStatus *APIRequestRetryStatus `json:"retryStatus,omitempty"`
|
||||
}
|
||||
|
||||
// APIRequestRetryStatus represents retry status information
|
||||
type APIRequestRetryStatus struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySec int `json:"delaySec"`
|
||||
ErrorSnippet string `json:"errorSnippet,omitempty"`
|
||||
}
|
||||
|
||||
// GetTimestamp returns a formatted timestamp string
|
||||
func (m *ClineMessage) GetTimestamp() string {
|
||||
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
|
||||
}
|
||||
|
||||
// IsAsk returns true if this is an ASK message
|
||||
func (m *ClineMessage) IsAsk() bool {
|
||||
return m.Type == MessageTypeAsk
|
||||
}
|
||||
|
||||
// IsSay returns true if this is a SAY message
|
||||
func (m *ClineMessage) IsSay() bool {
|
||||
return m.Type == MessageTypeSay
|
||||
}
|
||||
|
||||
// GetMessageKey returns a unique key for this message based on timestamp
|
||||
func (m *ClineMessage) GetMessageKey() string {
|
||||
return strconv.FormatInt(m.Timestamp, 10)
|
||||
}
|
||||
|
||||
// ExtractMessagesFromStateJSON parses the state JSON and extracts messages
|
||||
func ExtractMessagesFromStateJSON(stateJson string) ([]*ClineMessage, error) {
|
||||
// Parse the state JSON to extract clineMessages
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Try to extract clineMessages
|
||||
clineMessagesRaw, exists := rawState["clineMessages"]
|
||||
if !exists {
|
||||
return []*ClineMessage{}, nil
|
||||
}
|
||||
|
||||
// Convert to JSON and back to get proper Message structs
|
||||
clineMessagesJson, err := json.Marshal(clineMessagesRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
var messages []*ClineMessage
|
||||
if err := json.Unmarshal(clineMessagesJson, &messages); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal clineMessages: %w", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ConvertProtoToMessage converts a protobuf ClineMessage to our local Message struct
|
||||
func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
var msgType MessageType
|
||||
var say, ask string
|
||||
|
||||
// Convert message type
|
||||
switch protoMsg.Type {
|
||||
case cline.ClineMessageType_ASK:
|
||||
msgType = MessageTypeAsk
|
||||
ask = convertProtoAskType(protoMsg.Ask)
|
||||
case cline.ClineMessageType_SAY:
|
||||
msgType = MessageTypeSay
|
||||
say = convertProtoSayType(protoMsg.Say)
|
||||
default:
|
||||
msgType = MessageTypeSay
|
||||
say = "unknown"
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
LastCheckpointHash: protoMsg.LastCheckpointHash,
|
||||
IsCheckpointCheckedOut: protoMsg.IsCheckpointCheckedOut,
|
||||
IsOperationOutsideWorkspace: protoMsg.IsOperationOutsideWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoAskType converts protobuf ask type to string
|
||||
func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
switch askType {
|
||||
case cline.ClineAsk_FOLLOWUP:
|
||||
return string(AskTypeFollowup)
|
||||
case cline.ClineAsk_PLAN_MODE_RESPOND:
|
||||
return string(AskTypePlanModeRespond)
|
||||
case cline.ClineAsk_COMMAND:
|
||||
return string(AskTypeCommand)
|
||||
case cline.ClineAsk_COMMAND_OUTPUT:
|
||||
return string(AskTypeCommandOutput)
|
||||
case cline.ClineAsk_COMPLETION_RESULT:
|
||||
return string(AskTypeCompletionResult)
|
||||
case cline.ClineAsk_TOOL:
|
||||
return string(AskTypeTool)
|
||||
case cline.ClineAsk_API_REQ_FAILED:
|
||||
return string(AskTypeAPIReqFailed)
|
||||
case cline.ClineAsk_RESUME_TASK:
|
||||
return string(AskTypeResumeTask)
|
||||
case cline.ClineAsk_RESUME_COMPLETED_TASK:
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
return string(AskTypeUseMcpServer)
|
||||
case cline.ClineAsk_NEW_TASK:
|
||||
return string(AskTypeNewTask)
|
||||
case cline.ClineAsk_CONDENSE:
|
||||
return string(AskTypeCondense)
|
||||
case cline.ClineAsk_REPORT_BUG:
|
||||
return string(AskTypeReportBug)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// convertProtoSayType converts protobuf say type to string
|
||||
func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
switch sayType {
|
||||
case cline.ClineSay_TASK:
|
||||
return string(SayTypeTask)
|
||||
case cline.ClineSay_ERROR:
|
||||
return string(SayTypeError)
|
||||
case cline.ClineSay_API_REQ_STARTED:
|
||||
return string(SayTypeAPIReqStarted)
|
||||
case cline.ClineSay_API_REQ_FINISHED:
|
||||
return string(SayTypeAPIReqFinished)
|
||||
case cline.ClineSay_TEXT:
|
||||
return string(SayTypeText)
|
||||
case cline.ClineSay_REASONING:
|
||||
return string(SayTypeReasoning)
|
||||
case cline.ClineSay_COMPLETION_RESULT_SAY:
|
||||
return string(SayTypeCompletionResult)
|
||||
case cline.ClineSay_USER_FEEDBACK:
|
||||
return string(SayTypeUserFeedback)
|
||||
case cline.ClineSay_USER_FEEDBACK_DIFF:
|
||||
return string(SayTypeUserFeedbackDiff)
|
||||
case cline.ClineSay_API_REQ_RETRIED:
|
||||
return string(SayTypeAPIReqRetried)
|
||||
case cline.ClineSay_ERROR_RETRY:
|
||||
return string(SayTypeErrorRetry)
|
||||
case cline.ClineSay_COMMAND_SAY:
|
||||
return string(SayTypeCommand)
|
||||
case cline.ClineSay_COMMAND_OUTPUT_SAY:
|
||||
return string(SayTypeCommandOutput)
|
||||
case cline.ClineSay_TOOL_SAY:
|
||||
return string(SayTypeTool)
|
||||
case cline.ClineSay_SHELL_INTEGRATION_WARNING:
|
||||
return string(SayTypeShellIntegrationWarning)
|
||||
case cline.ClineSay_BROWSER_ACTION_LAUNCH_SAY:
|
||||
return string(SayTypeBrowserActionLaunch)
|
||||
case cline.ClineSay_BROWSER_ACTION:
|
||||
return string(SayTypeBrowserAction)
|
||||
case cline.ClineSay_BROWSER_ACTION_RESULT:
|
||||
return string(SayTypeBrowserActionResult)
|
||||
case cline.ClineSay_MCP_SERVER_REQUEST_STARTED:
|
||||
return string(SayTypeMcpServerRequestStarted)
|
||||
case cline.ClineSay_MCP_SERVER_RESPONSE:
|
||||
return string(SayTypeMcpServerResponse)
|
||||
case cline.ClineSay_MCP_NOTIFICATION:
|
||||
return string(SayTypeMcpNotification)
|
||||
case cline.ClineSay_USE_MCP_SERVER_SAY:
|
||||
return string(SayTypeUseMcpServer)
|
||||
case cline.ClineSay_DIFF_ERROR:
|
||||
return string(SayTypeDiffError)
|
||||
case cline.ClineSay_DELETED_API_REQS:
|
||||
return string(SayTypeDeletedAPIReqs)
|
||||
case cline.ClineSay_CLINEIGNORE_ERROR:
|
||||
return string(SayTypeClineignoreError)
|
||||
case cline.ClineSay_CHECKPOINT_CREATED:
|
||||
return string(SayTypeCheckpointCreated)
|
||||
case cline.ClineSay_LOAD_MCP_DOCUMENTATION:
|
||||
return string(SayTypeLoadMcpDocumentation)
|
||||
case cline.ClineSay_INFO:
|
||||
return string(SayTypeInfo)
|
||||
case cline.ClineSay_TASK_PROGRESS:
|
||||
return string(SayTypeTaskProgress)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConversationState manages the state of the conversation
|
||||
type ConversationState struct {
|
||||
mu sync.RWMutex
|
||||
StreamingMessage *StreamingMessage `json:"streamingMessage,omitempty"`
|
||||
}
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
func NewConversationState() *ConversationState {
|
||||
return &ConversationState{
|
||||
StreamingMessage: &StreamingMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStreamingMessage updates the streaming message state
|
||||
func (cs *ConversationState) SetStreamingMessage(key, text string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage.CurrentKey = key
|
||||
cs.StreamingMessage.LastText = text
|
||||
}
|
||||
|
||||
// GetStreamingMessage returns the current streaming message state
|
||||
func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear resets state
|
||||
func (cs *ConversationState) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.StreamingMessage = &StreamingMessage{}
|
||||
}
|
||||
|
||||
// ExtensionState represents the server-side extension state structure
|
||||
type ExtensionState struct {
|
||||
CurrentTaskItem *CurrentTaskItem `json:"currentTaskItem,omitempty"`
|
||||
}
|
||||
|
||||
// CurrentTaskItem - minimal struct with just what we need
|
||||
type CurrentTaskItem struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// These will be set at build time via ldflags
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates the version command
|
||||
func NewVersionCommand() *cobra.Command {
|
||||
var short bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Aliases: []string{"v"},
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline Go host.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if short {
|
||||
fmt.Println(Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Cline Go Host\n")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Commit: %s\n", Commit)
|
||||
fmt.Printf("Built: %s\n", Date)
|
||||
fmt.Printf("Built by: %s\n", BuiltBy)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package common
|
||||
|
||||
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
|
||||
const DEFAULT_CLINE_CORE_PORT = 50052
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
// Database query constants for the SQLite locks database
|
||||
const (
|
||||
|
||||
// SelectInstanceLocksSQL selects all instance locks ordered by creation time
|
||||
SelectInstanceLocksSQL = `
|
||||
SELECT id, held_by, lock_type, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
SelectInstanceLockByHolderSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
SelectInstanceLockHoldersAscSQL = `
|
||||
SELECT held_by, lock_target, locked_at
|
||||
FROM locks
|
||||
WHERE lock_type = 'instance'
|
||||
ORDER BY locked_at ASC
|
||||
`
|
||||
|
||||
// DeleteInstanceLockSQL deletes an instance lock by address
|
||||
DeleteInstanceLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
InsertFileLockSQL = `
|
||||
INSERT INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'file', ?, ?)
|
||||
`
|
||||
|
||||
// DeleteFileLockSQL deletes a file lock by holder and target
|
||||
DeleteFileLockSQL = `
|
||||
DELETE FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'file' AND lock_target = ?
|
||||
`
|
||||
|
||||
// CountInstanceLockSQL counts instance locks for a given address
|
||||
CountInstanceLockSQL = `
|
||||
SELECT COUNT(*) FROM locks
|
||||
WHERE held_by = ? AND lock_type = 'instance'
|
||||
`
|
||||
|
||||
// InsertInstanceLockSQL inserts or replaces an instance lock
|
||||
InsertInstanceLockSQL = `
|
||||
INSERT OR REPLACE INTO locks (held_by, lock_type, lock_target, locked_at)
|
||||
VALUES (?, 'instance', ?, ?)
|
||||
`
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// CoreInstanceInfo represents a discovered Cline instance
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
Address string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
ProcessPID int `json:"process_pid,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.Address)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) HostPort() int {
|
||||
_, port, _ := ParseHostPort(c.HostServiceAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) StatusString() string {
|
||||
return c.Status.String()
|
||||
}
|
||||
|
||||
// LockRow represents a row in the locks table
|
||||
type LockRow struct {
|
||||
ID int64 `json:"id"`
|
||||
HeldBy string `json:"held_by"`
|
||||
LockType string `json:"lock_type"`
|
||||
LockTarget string `json:"lock_target"`
|
||||
LockedAt int64 `json:"locked_at"`
|
||||
}
|
||||
|
||||
// InstancesOutput represents the JSON output format for instance listing
|
||||
type InstancesOutput struct {
|
||||
DefaultInstance string `json:"default_instance"`
|
||||
CoreInstances []CoreInstanceInfo `json:"instances"`
|
||||
}
|
||||
|
||||
type DefaultCoreInstance struct {
|
||||
Address string `json:"default_instance"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ParseHostPort parses a host:port address and returns the host and port separately
|
||||
func ParseHostPort(address string) (string, int, error) {
|
||||
host, portStr, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
// IsLocalAddress checks if the given host is a local/loopback address
|
||||
// Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
|
||||
func IsLocalAddress(host string) bool {
|
||||
// Handle common localhost names
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse as IP and check if it's a loopback
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PerformHealthCheck performs a gRPC health check on the given address
|
||||
// Will return UNKNOWN if the service is unreachable (error)
|
||||
func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
|
||||
conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
healthClient := grpc_health_v1.NewHealthClient(conn)
|
||||
resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
|
||||
if err != nil {
|
||||
return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
// It's healthy if we can reach it and it responds with SERVING
|
||||
func IsInstanceHealthy(ctx context.Context, address string) bool {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
|
||||
}
|
||||
|
||||
// It's (likely) our instance if we can reach it and it responds to health checks
|
||||
func IsInstanceOurs(ctx context.Context, address string) bool {
|
||||
_, err := PerformHealthCheck(ctx, address)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// (unreachable or not serving)
|
||||
func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
|
||||
status, err := PerformHealthCheck(ctx, address)
|
||||
isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
|
||||
return status, isStale, err
|
||||
}
|
||||
|
||||
// IsPortAvailable checks if a port is available for binding
|
||||
func IsPortAvailable(port int) bool {
|
||||
address := fmt.Sprintf("localhost:%d", port)
|
||||
listener, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// FindAvailablePortPair finds two available ports by letting the OS allocate them
|
||||
func FindAvailablePortPair() (corePort, hostPort int, err error) {
|
||||
coreListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer coreListener.Close()
|
||||
|
||||
hostListener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer hostListener.Close()
|
||||
|
||||
corePort = coreListener.Addr().(*net.TCPAddr).Port
|
||||
hostPort = hostListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
return corePort, hostPort, nil
|
||||
}
|
||||
|
||||
// NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
|
||||
func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
host, port, err := ParseHostPort(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Normalize local addresses to localhost for gRPC compatibility
|
||||
if IsLocalAddress(host) {
|
||||
return fmt.Sprintf("localhost:%d", port), nil
|
||||
}
|
||||
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
|
||||
|
||||
// Create a channel to capture the operation result
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- operation()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil // Success
|
||||
}
|
||||
lastErr = err
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Add delay between attempts (except for the last one)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package generated
|
||||
|
||||
// FieldOverrides allows manual control over field relevance per provider
|
||||
// This file is NOT auto-generated and can be edited manually to override
|
||||
// the automatic field filtering logic.
|
||||
//
|
||||
// Usage:
|
||||
// - Add provider-specific overrides to force include/exclude fields
|
||||
// - true = force include this field for this provider
|
||||
// - false = force exclude this field for this provider
|
||||
// - If no override exists, automatic filtering logic applies
|
||||
var FieldOverrides = map[string]map[string]bool{
|
||||
// Format: "provider_id": {"field_name": shouldInclude}
|
||||
|
||||
// Example overrides (uncomment and modify as needed):
|
||||
|
||||
// "anthropic": {
|
||||
// "requestTimeoutMs": true, // explicitly include
|
||||
// "ollamaBaseUrl": false, // explicitly exclude
|
||||
// },
|
||||
|
||||
// "bedrock": {
|
||||
// "awsSessionToken": true, // include even if marked optional
|
||||
// "azureApiVersion": false, // exclude even if general
|
||||
// },
|
||||
|
||||
// Add more provider-specific overrides as needed
|
||||
}
|
||||
|
||||
// GetFieldOverride returns the override setting for a field, if one exists
|
||||
// Returns (shouldInclude, hasOverride)
|
||||
func GetFieldOverride(providerID, fieldName string) (bool, bool) {
|
||||
if providerOverrides, exists := FieldOverrides[providerID]; exists {
|
||||
if override, hasOverride := providerOverrides[fieldName]; hasOverride {
|
||||
return override, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// diffSession represents an in-memory diff editing session
|
||||
type diffSession struct {
|
||||
originalPath string // File path from OpenDiff request
|
||||
originalContent []byte // Original file content (for comparison)
|
||||
currentContent []byte // Current modified content
|
||||
lines []string // Current content split into lines
|
||||
encoding string // File encoding (default: utf8)
|
||||
}
|
||||
|
||||
// DiffService implements the proto.DiffServiceServer interface
|
||||
type DiffService struct {
|
||||
proto.UnimplementedDiffServiceServer
|
||||
verbose bool
|
||||
sessions *sync.Map // thread-safe: diffId -> *diffSession
|
||||
counter *int64 // atomic counter for unique IDs
|
||||
}
|
||||
|
||||
// NewDiffService creates a new DiffService
|
||||
func NewDiffService(verbose bool) *DiffService {
|
||||
counter := int64(0)
|
||||
return &DiffService{
|
||||
verbose: verbose,
|
||||
sessions: &sync.Map{},
|
||||
counter: &counter,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDiffID creates a unique diff ID
|
||||
func (s *DiffService) generateDiffID() string {
|
||||
id := atomic.AddInt64(s.counter, 1)
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
current := ""
|
||||
|
||||
for _, char := range content {
|
||||
if char == '\n' {
|
||||
lines = append(lines, current)
|
||||
current = ""
|
||||
} else if char != '\r' { // Skip \r characters, handle \r\n as \n
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// joinLines joins lines back into content with newlines
|
||||
func joinLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// OpenDiff opens a diff view for the specified file
|
||||
func (s *DiffService) OpenDiff(ctx context.Context, req *proto.OpenDiffRequest) (*proto.OpenDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenDiff called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
diffID := s.generateDiffID()
|
||||
|
||||
var originalContent []byte
|
||||
|
||||
// Check if file exists and read original content
|
||||
if req.GetPath() != "" {
|
||||
if _, err := os.Stat(req.GetPath()); err == nil {
|
||||
// File exists, read its content
|
||||
var readErr error
|
||||
originalContent, readErr = ioutil.ReadFile(req.GetPath())
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read original file: %w", readErr)
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, use empty content
|
||||
originalContent = []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided content as the initial current content
|
||||
currentContent := []byte(req.GetContent())
|
||||
|
||||
// Create the diff session
|
||||
session := &diffSession{
|
||||
originalPath: req.GetPath(),
|
||||
originalContent: originalContent,
|
||||
currentContent: currentContent,
|
||||
lines: splitLines(req.GetContent()),
|
||||
encoding: "utf8", // Default encoding
|
||||
}
|
||||
|
||||
// Store the session
|
||||
s.sessions.Store(diffID, session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Created diff session: %s (original: %d bytes, current: %d bytes)",
|
||||
diffID, len(originalContent), len(currentContent))
|
||||
}
|
||||
|
||||
return &proto.OpenDiffResponse{
|
||||
DiffId: &diffID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDocumentText returns the current content of the diff document
|
||||
func (s *DiffService) GetDocumentText(ctx context.Context, req *proto.GetDocumentTextRequest) (*proto.GetDocumentTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDocumentText called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
content := string(session.currentContent)
|
||||
|
||||
return &proto.GetDocumentTextResponse{
|
||||
Content: &content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReplaceText replaces text in the diff document using line-based operations
|
||||
func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextRequest) (*proto.ReplaceTextResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ReplaceText called for diff ID: %s, lines %d-%d",
|
||||
req.GetDiffId(), req.GetStartLine(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
startLine := int(req.GetStartLine())
|
||||
endLine := int(req.GetEndLine())
|
||||
newContent := req.GetContent()
|
||||
|
||||
// Validate line ranges
|
||||
if startLine < 0 {
|
||||
startLine = 0
|
||||
}
|
||||
if endLine < startLine {
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
}
|
||||
|
||||
// Replace the specified line range
|
||||
if endLine > len(session.lines) {
|
||||
// Extending beyond current content - append new lines
|
||||
session.lines = append(session.lines[:startLine], newLines...)
|
||||
} else {
|
||||
// Replace within existing content
|
||||
result := make([]string, 0, len(session.lines)-endLine+startLine+len(newLines))
|
||||
result = append(result, session.lines[:startLine]...)
|
||||
result = append(result, newLines...)
|
||||
result = append(result, session.lines[endLine:]...)
|
||||
session.lines = result
|
||||
}
|
||||
|
||||
// Update current content
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Updated diff session %s: %d lines, %d bytes",
|
||||
req.GetDiffId(), len(session.lines), len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.ReplaceTextResponse{}, nil
|
||||
}
|
||||
|
||||
// ScrollDiff scrolls the diff view to a specific line (no-op for CLI)
|
||||
func (s *DiffService) ScrollDiff(ctx context.Context, req *proto.ScrollDiffRequest) (*proto.ScrollDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ScrollDiff called for diff ID: %s, line: %d", req.GetDiffId(), req.GetLine())
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
if _, exists := s.sessions.Load(req.GetDiffId()); !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// In a CLI implementation, scrolling is a no-op
|
||||
// In a GUI implementation, this would scroll the view to the specified line
|
||||
return &proto.ScrollDiffResponse{}, nil
|
||||
}
|
||||
|
||||
// TruncateDocument truncates the diff document at the specified line
|
||||
func (s *DiffService) TruncateDocument(ctx context.Context, req *proto.TruncateDocumentRequest) (*proto.TruncateDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("TruncateDocument called for diff ID: %s, end line: %d", req.GetDiffId(), req.GetEndLine())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
endLine := int(req.GetEndLine())
|
||||
|
||||
// Truncate lines at the specified position
|
||||
if endLine >= 0 && endLine < len(session.lines) {
|
||||
session.lines = session.lines[:endLine]
|
||||
session.currentContent = []byte(joinLines(session.lines))
|
||||
|
||||
// Store the updated session
|
||||
s.sessions.Store(req.GetDiffId(), session)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Truncated diff session %s to %d lines", req.GetDiffId(), len(session.lines))
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.TruncateDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// SaveDocument saves the diff document to the original file
|
||||
func (s *DiffService) SaveDocument(ctx context.Context, req *proto.SaveDocumentRequest) (*proto.SaveDocumentResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveDocument called for diff ID: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
sessionInterface, exists := s.sessions.Load(req.GetDiffId())
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("diff session not found: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
session := sessionInterface.(*diffSession)
|
||||
|
||||
if session.originalPath == "" {
|
||||
return nil, fmt.Errorf("no file path specified for diff session: %s", req.GetDiffId())
|
||||
}
|
||||
|
||||
// Create parent directories if they don't exist
|
||||
dir := filepath.Dir(session.originalPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
// Write the current content to the original file
|
||||
if err := ioutil.WriteFile(session.originalPath, session.currentContent, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Saved diff session %s to file: %s (%d bytes)",
|
||||
req.GetDiffId(), session.originalPath, len(session.currentContent))
|
||||
}
|
||||
|
||||
return &proto.SaveDocumentResponse{}, nil
|
||||
}
|
||||
|
||||
// CloseAllDiffs closes all diff views and cleans up all sessions
|
||||
func (s *DiffService) CloseAllDiffs(ctx context.Context, req *proto.CloseAllDiffsRequest) (*proto.CloseAllDiffsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("CloseAllDiffs called")
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
// Optional: attempt to close if the value supports it
|
||||
if c, ok := value.(interface{ Close() error }); ok {
|
||||
_ = c.Close() // best-effort; ignore error
|
||||
}
|
||||
|
||||
s.sessions.Delete(key)
|
||||
atomic.AddInt64(&count, 1)
|
||||
return true
|
||||
})
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Closed %d diff sessions", count)
|
||||
}
|
||||
|
||||
return &proto.CloseAllDiffsResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenMultiFileDiff displays a diff view comparing before/after states for multiple files
|
||||
func (s *DiffService) OpenMultiFileDiff(ctx context.Context, req *proto.OpenMultiFileDiffRequest) (*proto.OpenMultiFileDiffResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("OpenMultiFileDiff called with title: %s, %d files", req.GetTitle(), len(req.GetDiffs()))
|
||||
}
|
||||
|
||||
// In a CLI implementation, we could display the diffs to console
|
||||
// For now, we'll just log the information
|
||||
title := req.GetTitle()
|
||||
if title == "" {
|
||||
title = "Multi-file diff"
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("=== %s ===", title)
|
||||
for i, diff := range req.GetDiffs() {
|
||||
log.Printf("File %d: %s", i+1, diff.GetFilePath())
|
||||
log.Printf(" Left content: %d bytes", len(diff.GetLeftContent()))
|
||||
log.Printf(" Right content: %d bytes", len(diff.GetRightContent()))
|
||||
}
|
||||
}
|
||||
|
||||
// In a more sophisticated CLI implementation, we could:
|
||||
// 1. Use a diff library to generate unified diffs
|
||||
// 2. Display them with colors
|
||||
// 3. Allow navigation between files
|
||||
// For now, this is a no-op that just acknowledges the request
|
||||
|
||||
return &proto.OpenMultiFileDiffResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WatchService implements the host.WatchServiceServer interface
|
||||
type WatchService struct {
|
||||
host.UnimplementedWatchServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWatchService creates a new WatchService
|
||||
func NewWatchService(coreAddress string, verbose bool) *WatchService {
|
||||
return &WatchService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToFile subscribes to file change notifications
|
||||
func (s *WatchService) SubscribeToFile(req *host.SubscribeToFileRequest, stream host.WatchService_SubscribeToFileServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToFile called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would watch the file
|
||||
// In a real implementation, we'd use fsnotify or similar to watch file changes
|
||||
log.Printf("[Cline] Would watch file: %s", req.GetPath())
|
||||
|
||||
// Keep the stream open but don't send any events for now
|
||||
// In a real implementation, we'd send FileChangeEvent messages when files change
|
||||
<-stream.Context().Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
proto "github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WindowService implements the proto.WindowServiceServer interface
|
||||
type WindowService struct {
|
||||
proto.UnimplementedWindowServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWindowService creates a new WindowService
|
||||
func NewWindowService(coreAddress string, verbose bool) *WindowService {
|
||||
return &WindowService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ShowTextDocument opens a text document for viewing/editing
|
||||
func (s *WindowService) ShowTextDocument(ctx context.Context, req *proto.ShowTextDocumentRequest) (*proto.TextEditorInfo, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowTextDocument called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would open the document
|
||||
fmt.Printf("[Cline] Would open document: %s\n", req.GetPath())
|
||||
|
||||
return &proto.TextEditorInfo{
|
||||
DocumentPath: req.GetPath(),
|
||||
IsActive: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowOpenDialogue shows a file open dialog
|
||||
func (s *WindowService) ShowOpenDialogue(ctx context.Context, req *proto.ShowOpenDialogueRequest) (*proto.SelectedResources, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowOpenDialogue called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty list (user cancelled)
|
||||
return &proto.SelectedResources{
|
||||
Paths: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShowMessage displays a message to the user
|
||||
func (s *WindowService) ShowMessage(ctx context.Context, req *proto.ShowMessageRequest) (*proto.SelectedResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ShowMessage called: %s", req.GetMessage())
|
||||
}
|
||||
|
||||
// Display message to console
|
||||
fmt.Printf("[Cline] %s\n", req.GetMessage())
|
||||
|
||||
return &proto.SelectedResponse{}, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WorkspaceService implements the host.WorkspaceServiceServer interface
|
||||
type WorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService
|
||||
func NewWorkspaceService(coreAddress string, verbose bool) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: false, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
Diagnostics: []*host.Diagnostic{},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
var globalShutdownCh chan struct{}
|
||||
|
||||
func init() {
|
||||
globalShutdownCh = make(chan struct{})
|
||||
}
|
||||
|
||||
// EnvService implements the host.EnvServiceServer interface
|
||||
type EnvService struct {
|
||||
host.UnimplementedEnvServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewEnvService creates a new EnvService
|
||||
func NewEnvService(verbose bool) *EnvService {
|
||||
return &EnvService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue()))
|
||||
}
|
||||
|
||||
err := clipboard.WriteAll(req.GetValue())
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to write to clipboard: %v", err)
|
||||
}
|
||||
// Don't fail if clipboard is not available (e.g., headless environment)
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// ClipboardReadText reads text from the system clipboard
|
||||
func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to read from clipboard: %v", err)
|
||||
}
|
||||
// Return empty string if clipboard is not available
|
||||
text = ""
|
||||
}
|
||||
|
||||
return &cline.String{
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHostVersion returns the host platform name and version
|
||||
func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest) (*host.GetHostVersionResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(cli.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown requested via RPC")
|
||||
}
|
||||
|
||||
// Trigger global shutdown signal
|
||||
select {
|
||||
case globalShutdownCh <- struct{}{}:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal sent successfully")
|
||||
}
|
||||
default:
|
||||
if s.verbose {
|
||||
log.Printf("Shutdown signal already pending")
|
||||
}
|
||||
}
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// GrpcServer provides gRPC hostbridge functionality
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the gRPC hostbridge server
|
||||
func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting gRPC hostbridge server on port %d", s.port)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on port %d: %w", s.port, err)
|
||||
}
|
||||
|
||||
// Create gRPC server
|
||||
s.server = grpc.NewServer()
|
||||
|
||||
// Register health service
|
||||
healthServer := health.NewServer()
|
||||
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
host.RegisterWindowServiceServer(s.server, windowService)
|
||||
|
||||
diffService := NewDiffService(s.verbose)
|
||||
host.RegisterDiffServiceServer(s.server, diffService)
|
||||
|
||||
envService := NewEnvService(s.verbose)
|
||||
host.RegisterEnvServiceServer(s.server, envService)
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Registered HealthService")
|
||||
log.Printf("Registered WorkspaceService")
|
||||
log.Printf("Registered WindowService")
|
||||
log.Printf("Registered DiffService")
|
||||
log.Printf("Registered EnvService")
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if s.verbose {
|
||||
log.Printf("gRPC server listening on :%d", s.port)
|
||||
}
|
||||
if err := s.server.Serve(lis); err != nil {
|
||||
log.Printf("gRPC server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or global shutdown signal
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if s.verbose {
|
||||
log.Println("Context cancelled, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
case <-globalShutdownCh:
|
||||
if s.verbose {
|
||||
log.Println("Shutdown requested via RPC, shutting down gRPC hostbridge server...")
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
s.server.GracefulStop()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("gRPC hostbridge server stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a graceful shutdown of the server
|
||||
func (s *GrpcServer) TriggerShutdown() {
|
||||
select {
|
||||
case s.shutdownCh <- struct{}{}:
|
||||
// Shutdown signal sent
|
||||
default:
|
||||
// Channel already has a signal or is closed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Simple implementations that don't rely on proto files for now
|
||||
// This allows us to test the basic hostbridge structure
|
||||
|
||||
// SimpleService provides basic hostbridge functionality
|
||||
type SimpleService struct {
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleService creates a new SimpleService
|
||||
func NewSimpleService(coreAddress string, verbose bool) *SimpleService {
|
||||
return &SimpleService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the simple hostbridge service
|
||||
func (s *SimpleService) Start(ctx context.Context) error {
|
||||
if s.verbose {
|
||||
log.Printf("Starting simple hostbridge service (connecting to core at %s)", s.coreAddress)
|
||||
}
|
||||
|
||||
// For now, just log that we're running
|
||||
fmt.Printf("[Cline Host Bridge] Service started on core address: %s\n", s.coreAddress)
|
||||
|
||||
// Keep running until context is cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Println("Simple hostbridge service stopped")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// SimpleWorkspaceService implements a basic workspace service without complex dependencies
|
||||
type SimpleWorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewSimpleWorkspaceService creates a new SimpleWorkspaceService
|
||||
func NewSimpleWorkspaceService(verbose bool) *SimpleWorkspaceService {
|
||||
return &SimpleWorkspaceService{
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkspacePaths returns the workspace directory paths
|
||||
func (s *SimpleWorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetWorkspacePathsRequest) (*host.GetWorkspacePathsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *SimpleWorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetFilePath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
wasSaved := false
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: &wasSaved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file - simplified version
|
||||
func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenProblemsPanel opens the problems panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenProblemsPanel(ctx context.Context, req *host.OpenProblemsPanelRequest) (*host.OpenProblemsPanelResponse, error) {
|
||||
return &host.OpenProblemsPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenInFileExplorerPanel opens a file/folder in the file explorer - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenInFileExplorerPanel(ctx context.Context, req *host.OpenInFileExplorerPanelRequest) (*host.OpenInFileExplorerPanelResponse, error) {
|
||||
return &host.OpenInFileExplorerPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenClineSidebarPanel opens the Cline sidebar panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenClineSidebarPanel(ctx context.Context, req *host.OpenClineSidebarPanelRequest) (*host.OpenClineSidebarPanelResponse, error) {
|
||||
return &host.OpenClineSidebarPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenTerminalPanel opens the terminal panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenTerminalPanel(ctx context.Context, req *host.OpenTerminalRequest) (*host.OpenTerminalResponse, error) {
|
||||
return &host.OpenTerminalResponse{}, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user