mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe41da67b6 | |||
| 08cef66956 | |||
| 25e1e0a58b | |||
| 1b0a63f1cc | |||
| 89ecf79c19 | |||
| a8027dc570 | |||
| 978a8a0aa6 | |||
| 302bc4f1b4 | |||
| 0cd462a414 | |||
| 65dbd85a92 | |||
| ee1bb2f788 | |||
| 6f69ffb16f | |||
| 4da7817b7e | |||
| f91769bda7 | |||
| 7692adacf5 | |||
| a98faf5af4 | |||
| e3f4ce618f | |||
| dcf519d2f7 | |||
| 3ef4aea0f7 | |||
| a820026e0b | |||
| fd9ecb1c7f | |||
| cf41695823 | |||
| 54fc95dbe8 | |||
| 2969810a44 | |||
| 29d1b0507c | |||
| 929d13a4dd | |||
| c729e8c7c6 | |||
| c1b526c154 | |||
| ed58c05dc2 | |||
| 737452b2b1 | |||
| ba6a72cf15 | |||
| 2c2f1bc3e1 | |||
| 65a0c35163 | |||
| 0c8e02c6e4 | |||
| b636018ef5 | |||
| cfc6b0d7f5 | |||
| b5890d341f | |||
| 5e29e66aca | |||
| a83354a7e4 | |||
| f116a6323d | |||
| 7b5400805a | |||
| 2b97a097e4 | |||
| 46d3b2a3ed | |||
| 9679917532 | |||
| 89bf81f7f6 | |||
| afe01df8b4 | |||
| cfe94739ef | |||
| eb1325686e | |||
| 7c7962ce0f | |||
| 0707df2205 | |||
| ca87c21b77 | |||
| d6f736e8d5 | |||
| 4336471d84 | |||
| 3191e23c1d | |||
| c5f12b8dc6 | |||
| b21ff1e44a | |||
| 2860ffe147 | |||
| 2b25ef63b5 | |||
| e70d60d5c4 | |||
| bddbea04ef | |||
| 176ccedb2a | |||
| 63276aba70 | |||
| ae0f2557fe | |||
| 0d9909c80e | |||
| c0b4081a53 | |||
| 0c0ba93a41 | |||
| 513c518d19 | |||
| 03d6561383 | |||
| 8f8c4561a6 | |||
| 45d751b913 | |||
| 915259ca96 | |||
| 46ef8b10b0 | |||
| e4e07fc0d3 | |||
| 738e959030 | |||
| 16e1c02b98 | |||
| 9e3c3982ec | |||
| 2ed5ce9b15 | |||
| 7dceaea056 | |||
| b351a8b92b | |||
| 78c3664bf3 | |||
| ec543a230f | |||
| 43fabaab8e | |||
| 86c526b029 | |||
| 7162b26430 | |||
| e9eb7ae179 | |||
| 546e7002e1 | |||
| fadc961d8f | |||
| 958801e8a8 | |||
| 5152b970c0 | |||
| 947996b02e | |||
| 0a25484ea0 | |||
| 91c5434b5e | |||
| 6f59480b79 | |||
| e42f0a9aea | |||
| 385ac33623 | |||
| 6b963c243a | |||
| 66a4eb0f8e | |||
| 9a7c6ed201 | |||
| c7143a627a | |||
| b09751b841 | |||
| 7c51236aef | |||
| f47d07784a | |||
| 65e5f0feb7 | |||
| b2e3e9b3f9 | |||
| 7be84fd5bf | |||
| 43683440dc | |||
| 153e24b94a | |||
| 65d46a3691 | |||
| aa2cbc39a4 | |||
| 689e7f0e13 | |||
| 3c3188073b | |||
| 8e3ee11966 | |||
| 4d525e065c | |||
| 5832ca4792 | |||
| d85fea15c9 | |||
| bdfb7ff9c5 | |||
| 57b0763b8b | |||
| 9be3fa5acf | |||
| e76d9527c8 | |||
| 6745bdc79b | |||
| 899af48677 | |||
| 4e645ac853 | |||
| e8b8ec4f05 | |||
| dc99753a59 | |||
| 664ec1e0ac | |||
| e34c62ade9 | |||
| ffabde6985 | |||
| a8c62dddc6 | |||
| 2b5d6e5d0e | |||
| 0440898584 | |||
| 3288defb67 | |||
| ec6daccf5f | |||
| e399a70bd0 | |||
| 6e57263eca | |||
| 801310222e | |||
| b52edae01e | |||
| 467838ac4c | |||
| 9be95f2401 | |||
| cab5183297 | |||
| 814988c929 | |||
| 7b7a006123 | |||
| 08286a8465 | |||
| d252c84a04 | |||
| 9cd2729d47 | |||
| bb94a572ac | |||
| a7ec39270b | |||
| 600bcab19a | |||
| 870e58280d | |||
| 010a23c795 | |||
| da9dd52bf2 | |||
| 85bf52036a | |||
| 5e768aceae | |||
| c166d36788 | |||
| fc8517b52d | |||
| c2f98b6ed7 | |||
| 98c84cdc8f | |||
| cdffc002eb | |||
| 3dc09d698d | |||
| c04e2185f9 | |||
| 3846e3fca0 | |||
| 663e75203b | |||
| c5d153551c | |||
| 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 | |||
| ff5b6584bc | |||
| 04204c14bf | |||
| 40a5f5ffad | |||
| 9fff0bd34d | |||
| 2e7dd7f841 | |||
| 18cf97431c | |||
| 805fb85371 | |||
| 488e377a44 | |||
| 16a378840b | |||
| 0bbc718766 | |||
| 74c6d1d2bc | |||
| 82b549c2ca |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixed proto naming issue - RPC >>> Rpc
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added checkpoints warning when users start a multiroot task
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support Feature Flags default values
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Adding oca as a provider to cline cli
|
||||
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
|
||||
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,375 @@
|
||||
# Cline Hooks Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks can be placed in either:
|
||||
- **Global hooks directory**: `~/Documents/Cline/Rules/Hooks/` (applies to all workspaces)
|
||||
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to specific workspace)
|
||||
|
||||
Hooks 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
|
||||
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreToolUse` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/PreToolUse` (all platforms)
|
||||
|
||||
### PostToolUse Hook
|
||||
- **When**: Runs AFTER a tool completes
|
||||
- **Purpose**: Observe results, track patterns, or add context
|
||||
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PostToolUse` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
|
||||
|
||||
## Cross-Platform Hook Format
|
||||
|
||||
Cline uses a git-style approach for hooks that works consistently across all platforms:
|
||||
|
||||
### Hook Files (All Platforms)
|
||||
- **No file extensions**: Hooks are named exactly `PreToolUse` or `PostToolUse` (no `.bat`, `.cmd`, `.sh` etc.)
|
||||
- **Shebang required**: First line must be a shebang (e.g., `#!/usr/bin/env bash` or `#!/usr/bin/env node`)
|
||||
- **Executable on Unix**: On Unix/Linux/macOS, hooks must be executable: `chmod +x PreToolUse`
|
||||
- **Windows**: No special permissions needed - hooks are executed through the shell
|
||||
|
||||
### How It Works
|
||||
|
||||
Like git hooks, Cline executes hook files through a shell that interprets the shebang line:
|
||||
- On Unix/Linux/macOS: Native shell execution with shebang support
|
||||
- On Windows: Shell execution handles shebang interpretation
|
||||
|
||||
This means:
|
||||
- ✅ Same hook script works on all platforms
|
||||
- ✅ Write once, run anywhere
|
||||
- ✅ Use any scripting language (bash, node, python, etc.)
|
||||
|
||||
### Creating Hooks
|
||||
|
||||
**On Unix/Linux/macOS:**
|
||||
```bash
|
||||
# Create hook file
|
||||
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
|
||||
|
||||
# Make executable
|
||||
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
|
||||
```
|
||||
|
||||
**On Windows:**
|
||||
```batch
|
||||
REM Create hook file (note: no file extension)
|
||||
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
|
||||
```
|
||||
|
||||
## 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}'
|
||||
```
|
||||
|
||||
## Global vs Workspace Hooks
|
||||
|
||||
Cline supports two levels of hooks:
|
||||
|
||||
### Global Hooks
|
||||
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows)
|
||||
- **Scope**: Apply to ALL workspaces and projects
|
||||
- **Use Case**: Organization-wide policies, personal preferences, universal validations
|
||||
- **Priority**: Execute FIRST, before workspace hooks
|
||||
|
||||
### Workspace Hooks
|
||||
- **Location**: `.clinerules/hooks/` in each workspace root
|
||||
- **Scope**: Apply only to the specific workspace
|
||||
- **Use Case**: Project-specific rules, team conventions, repository requirements
|
||||
- **Priority**: Execute AFTER global hooks
|
||||
|
||||
### Hook Execution
|
||||
|
||||
When multiple hooks exist (global and/or workspace):
|
||||
- All hooks for a given step (PreToolUse or PostToolUse) are executed
|
||||
- **Execution order is not guaranteed** - hooks may run concurrently
|
||||
- If ALL hooks allow execution (`shouldContinue: true`), the tool proceeds
|
||||
- If ANY hook blocks (`shouldContinue: false`), execution is blocked
|
||||
|
||||
**Result Combination:**
|
||||
- `shouldContinue`: Must be `true` from ALL hooks for execution to proceed
|
||||
- `contextModification`: All context strings are concatenated
|
||||
- `errorMessage`: All error messages are concatenated
|
||||
|
||||
### Setting Up Global Hooks
|
||||
|
||||
1. The global hooks directory is automatically created at:
|
||||
- macOS/Linux: `~/Documents/Cline/Rules/Hooks/`
|
||||
- Windows: `%USERPROFILE%\Documents\Cline\Rules\Hooks\`
|
||||
|
||||
2. Add your hook script:
|
||||
```bash
|
||||
# Unix/Linux/macOS
|
||||
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
|
||||
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
|
||||
|
||||
# Windows
|
||||
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
|
||||
```
|
||||
|
||||
3. Enable hooks in Cline settings
|
||||
|
||||
### Example: Global + Workspace Hooks
|
||||
|
||||
**Global Hook** (applies to all projects):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# ~/Documents/Cline/Rules/Hooks/PreToolUse
|
||||
# Universal rule: Never delete package.json
|
||||
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" == *"package.json"* ]]; then
|
||||
echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
**Workspace Hook** (applies to specific project):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# .clinerules/hooks/PreToolUse
|
||||
# Project rule: Only TypeScript files
|
||||
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
|
||||
echo '{"shouldContinue": false, "errorMessage": "Project rule: Use .ts files only"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently.
|
||||
|
||||
## Multi-Root Workspaces
|
||||
|
||||
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. 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
|
||||
|
||||
**Note:** No execution order is guaranteed between hooks from different directories.
|
||||
|
||||
## 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
|
||||
@@ -122,9 +122,9 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
title="Previous Updates:"
|
||||
classNames={{
|
||||
trigger: "bg-transparent border-0 pl-0 pb-0 w-fit",
|
||||
title: "font-bold text-[var(--vscode-foreground)]",
|
||||
title: "font-bold text-(--vscode-foreground)",
|
||||
indicator:
|
||||
"text-[var(--vscode-foreground)] mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
|
||||
"text-(--vscode-foreground) mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
|
||||
}}>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# 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
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
# OpenTelemetry provides flexible telemetry collection with multiple export options
|
||||
# Can run alongside PostHog or independently
|
||||
# Primary focus: Logs (events), with optional metrics support
|
||||
|
||||
# Enable OpenTelemetry (set to 1 to enable)
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
|
||||
# Exporters: "console" for local debugging, "otlp" for remote collector
|
||||
# Logs are the primary signal (recommended)
|
||||
# OTEL_LOGS_EXPORTER=console
|
||||
# OTEL_METRICS_EXPORTER=otlp
|
||||
|
||||
# OTLP Protocol: "grpc", "http/json", or "http/protobuf"
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
|
||||
# OTLP Endpoint (without /v1/logs or /v1/metrics path - auto-appended)
|
||||
# For gRPC: use "localhost:4317" (no http:// prefix)
|
||||
# For HTTP: use "http://localhost:4318"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||
|
||||
# OTLP Headers (for authentication, e.g., bearer tokens)
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token-here
|
||||
|
||||
# Use insecure gRPC connections (for local testing only, NOT for production)
|
||||
# OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
|
||||
# Metric export interval in milliseconds (default: 60000)
|
||||
# OTEL_METRIC_EXPORT_INTERVAL=10000
|
||||
|
||||
# Batch configuration for logs (optional)
|
||||
# OTEL_LOG_BATCH_SIZE=512 # Max logs per batch (default: 512)
|
||||
# OTEL_LOG_BATCH_TIMEOUT=5000 # Max wait time in ms (default: 5000)
|
||||
# OTEL_LOG_MAX_QUEUE_SIZE=2048 # Max queue size (default: 2048)
|
||||
|
||||
# Enable detailed export diagnostics (for debugging)
|
||||
# TEL_DEBUG_DIAGNOSTICS=true
|
||||
|
||||
# Advanced: Separate endpoints for metrics and logs (optional)
|
||||
# OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobuf
|
||||
# OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://metrics.example.com:4318
|
||||
# OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc
|
||||
# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=logs.example.com:4317
|
||||
|
||||
# Example configurations:
|
||||
#
|
||||
# Console debugging (logs only):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_LOGS_EXPORTER=console
|
||||
# TEL_DEBUG_DIAGNOSTICS=true
|
||||
#
|
||||
# OTLP with gRPC (insecure, for local testing):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
|
||||
# OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
#
|
||||
# OTLP with HTTP/JSON (production):
|
||||
# OTEL_TELEMETRY_ENABLED=1
|
||||
# OTEL_LOGS_EXPORTER=otlp
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
@@ -1,3 +1,4 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
|
||||
@@ -14,6 +14,7 @@ body:
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,12 @@ jobs:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
run: npm run publish:marketplace:nightly
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: ${{ secrets.OTEL_LOGS_EXPORTER }}
|
||||
OTEL_METRICS_EXPORTER: ${{ secrets.OTEL_METRICS_EXPORTER }}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
OTEL_METRIC_EXPORT_INTERVAL: ${{ secrets.OTEL_METRIC_EXPORT_INTERVAL }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
|
||||
@@ -97,6 +97,14 @@ jobs:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: ${{ secrets.OTEL_LOGS_EXPORTER }}
|
||||
OTEL_METRICS_EXPORTER: ${{ secrets.OTEL_METRICS_EXPORTER }}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
OTEL_METRIC_EXPORT_INTERVAL: ${{ secrets.OTEL_METRIC_EXPORT_INTERVAL }}
|
||||
run: |
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
@@ -187,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: Build CLI binaries
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Compile NPM package
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
@@ -244,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 }}
|
||||
|
||||
@@ -26,6 +26,7 @@ coverage-unit
|
||||
!.github/scripts/coverage/
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
|
||||
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",
|
||||
|
||||
+5
-1
@@ -18,6 +18,7 @@ tsconfig*.json
|
||||
eslint-rules/**
|
||||
.github/**
|
||||
.husky/**
|
||||
.env
|
||||
|
||||
# Custom
|
||||
**/demo.gif
|
||||
@@ -36,6 +37,9 @@ buf.yaml
|
||||
.changeset/
|
||||
.clinerules/
|
||||
|
||||
# Include specific file needed for Background Exec mode
|
||||
!standalone/runtime-files/vscode/enhanced-terminal.js
|
||||
|
||||
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
|
||||
webview-ui/src/**
|
||||
webview-ui/public/**
|
||||
@@ -69,4 +73,4 @@ test-results/
|
||||
**/*.stories.tsx
|
||||
*storybook.log
|
||||
storybook-static
|
||||
**/StorybookDecorator.tsx
|
||||
**/StorybookDecorator.tsx
|
||||
|
||||
+27
-1
@@ -1,10 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [3.34.0]
|
||||
|
||||
- Cline Teams is now free through 2025 for unlimited users. Includes Jetbrains, RBAC, centralized billing and more.
|
||||
- Use the “exacto” versions of GLM-4.6, Kimi-K2, and Qwen3-Coder in the Cline provider for the best balance of cost, speed, accuracy and tool-calling.
|
||||
|
||||
## [3.33.1]
|
||||
|
||||
- Fix CLI installation copy text
|
||||
|
||||
## [3.33.0]
|
||||
|
||||
- Added Cline CLI (Preview)
|
||||
- Added Subagent support (Experimental)
|
||||
- Added Multi-Root Workspaces support (Enable in feature settings)
|
||||
- Add auto-retry with exponential backof for failed API requests
|
||||
|
||||
## [3.32.8]
|
||||
|
||||
- Add Claude Haiku 4.5 support
|
||||
|
||||
## [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
|
||||
- Add Glm 4.6 to Z AI provider
|
||||
|
||||
## [3.32.5]
|
||||
|
||||
|
||||
+13
-13
@@ -70,7 +70,7 @@
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "warn"
|
||||
"noAssignInExpressions": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "off",
|
||||
@@ -82,7 +82,7 @@
|
||||
"noStaticOnlyClass": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "warn"
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,17 +114,17 @@
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist/**",
|
||||
"!**/dist-*/**",
|
||||
"!**/out/**",
|
||||
"!**/evals/**",
|
||||
"!**/playwright/**",
|
||||
"!**/test-results/**",
|
||||
"!**/node_modules/**",
|
||||
"!**/webview-ui/build/**",
|
||||
"!**/generated/**",
|
||||
"!**/proto/**",
|
||||
"!**/tests/specs/**"
|
||||
"!**/dist",
|
||||
"!**/dist-*",
|
||||
"!**/out",
|
||||
"!**/evals",
|
||||
"!**/playwright",
|
||||
"!**/test-results",
|
||||
"!**/node_modules",
|
||||
"!**/webview-ui/build",
|
||||
"!**/generated",
|
||||
"!**/proto",
|
||||
"!**/tests/specs"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
cline-core-debug.log
|
||||
bin/*
|
||||
bin/*
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Cline CLI
|
||||
|
||||
```
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
```
|
||||
|
||||
Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more.
|
||||
|
||||
## Installation
|
||||
|
||||
Install Cline globally using npm:
|
||||
|
||||
```bash
|
||||
npm install -g cline
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
This will start the Cline CLI interface where you can interact with the autonomous coding agent.
|
||||
|
||||
## Features
|
||||
|
||||
- **Autonomous Coding**: AI-powered code generation, editing, and refactoring
|
||||
- **File Operations**: Create, read, update, and delete files and directories
|
||||
- **Command Execution**: Run shell commands and scripts
|
||||
- **Browser Automation**: Interact with web pages and applications
|
||||
- **Multi-Model Support**: Works with Anthropic Claude, OpenAI GPT, and other AI models
|
||||
- **MCP Integration**: Extensible through Model Context Protocol servers
|
||||
- **Project Understanding**: Analyzes codebases to provide context-aware assistance
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18.0.0 or higher
|
||||
- Supported platforms: macOS, Linux. Windows soon
|
||||
- Supported architectures: x64, arm64
|
||||
|
||||
## Configuration
|
||||
|
||||
Cline can be configured through:
|
||||
|
||||
- Environment variables
|
||||
- Configuration files
|
||||
- Command-line arguments
|
||||
|
||||
See the [main documentation](https://cline.bot) for detailed configuration options.
|
||||
|
||||
## Links
|
||||
|
||||
- **Website**: [https://cline.bot](https://cline.bot)
|
||||
- **Documentation**: [https://docs.cline.bot](https://docs.cline.bot)
|
||||
- **GitHub**: [https://github.com/cline/cline](https://github.com/cline/cline)
|
||||
- **VSCode Extension**: Available in the VSCode Marketplace
|
||||
- **JetBrains Extension**: Available in the JetBrains Marketplace
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
- Report issues: [GitHub Issues](https://github.com/cline/cline/issues)
|
||||
- Community: [GitHub Discussions](https://github.com/cline/cline/discussions)
|
||||
- Documentation: [docs.cline.bot](https://docs.cline.bot)
|
||||
@@ -1,6 +0,0 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
+299
-8
@@ -2,56 +2,347 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"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
|
||||
cfgFile string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline",
|
||||
Use: "cline [prompt]",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
This CLI provides access to Cline's task management, configuration, and
|
||||
monitoring capabilities from the terminal.`,
|
||||
Start a new task by providing a prompt:
|
||||
cline "Create a new Python script that prints hello world"
|
||||
|
||||
Or pipe a prompt via stdin:
|
||||
echo "Create a todo app" | cline
|
||||
cat prompt.txt | cline --yolo
|
||||
|
||||
Or run with no arguments to enter interactive mode:
|
||||
cline
|
||||
|
||||
This CLI also provides task management, configuration, and monitoring capabilities.
|
||||
|
||||
For detailed documentation including all commands, options, and examples,
|
||||
see the manual page: man cline`,
|
||||
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{
|
||||
ConfigPath: cfgFile,
|
||||
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)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
|
||||
|
||||
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
|
||||
// Check if user cancelled - exit cleanly
|
||||
if err == huh.ErrUserAborted {
|
||||
return 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")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
|
||||
}
|
||||
} else {
|
||||
// User specified --address flag, use that
|
||||
instanceAddress = coreAddress
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// If no prompt from args or stdin, show interactive input
|
||||
if prompt == "" {
|
||||
// Pass the mode flag to banner so it shows correct mode
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode)
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
}
|
||||
|
||||
// If oneshot mode, force plan mode and yolo
|
||||
if oneshot {
|
||||
mode = "plan"
|
||||
yolo = true
|
||||
}
|
||||
|
||||
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Verbose: verbose,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cline/config.yaml)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "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().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.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewConfigCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
rootCmd.AddCommand(cli.NewLogsCommand())
|
||||
rootCmd.AddCommand(cli.NewDoctorCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) {
|
||||
// Show session banner before the initial input
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag)
|
||||
|
||||
var prompt string
|
||||
|
||||
// Create custom theme with mode-colored cursor and title
|
||||
theme := huh.ThemeCharm()
|
||||
|
||||
// Set cursor and title color based on mode
|
||||
modeColor := lipgloss.Color("3") // Yellow for plan
|
||||
if modeFlag == "act" {
|
||||
modeColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor)
|
||||
theme.Focused.Title = theme.Focused.Title.Foreground(modeColor)
|
||||
|
||||
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),
|
||||
),
|
||||
).WithWidth(48).WithTheme(theme)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
// Return a special error that indicates clean cancellation
|
||||
// This allows deferred cleanup to run
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(prompt), nil
|
||||
}
|
||||
|
||||
// showSessionBanner displays session info before initial prompt
|
||||
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
bannerInfo := display.BannerInfo{
|
||||
Version: global.CliVersion,
|
||||
Mode: modeFlag, // Use the mode from command flag, not state
|
||||
}
|
||||
|
||||
// If mode is empty, default to "plan"
|
||||
if bannerInfo.Mode == "" {
|
||||
bannerInfo.Mode = "plan"
|
||||
}
|
||||
|
||||
// Get current working directory (this is what Cline will use)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
bannerInfo.Workdir = cwd
|
||||
}
|
||||
|
||||
// Get provider/model using auth functions (same logic as auth menu)
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
if err == nil {
|
||||
if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil {
|
||||
// Show provider/model for the mode we'll be using
|
||||
var providerDisplay *auth.ProviderDisplay
|
||||
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
|
||||
providerDisplay = providerList.PlanProvider
|
||||
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
|
||||
providerDisplay = providerList.ActProvider
|
||||
}
|
||||
|
||||
if providerDisplay != nil {
|
||||
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
|
||||
bannerInfo.ModelID = providerDisplay.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render and display banner
|
||||
banner := display.RenderSessionBanner(bannerInfo)
|
||||
fmt.Println(banner)
|
||||
fmt.Println() // Extra spacing before form
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Only try to read if there's actually data available
|
||||
if stat.Size() > 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
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanc
|
||||
return []common.CoreInstanceInfo{}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to open SQLite database: %v", err)
|
||||
return []common.CoreInstanceInfo{}
|
||||
@@ -97,7 +97,7 @@ func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
|
||||
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePo
|
||||
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Logf("Failed to open database: %v", err)
|
||||
return false
|
||||
|
||||
+44
-2
@@ -3,20 +3,62 @@ module github.com/cline/cli
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
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/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // 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/google/uuid v1.6.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
)
|
||||
|
||||
+116
-2
@@ -1,4 +1,64 @@
|
||||
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.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
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.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
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.1-0.20251005153135-a01a1e304532 h1:+xmbw70JXxmsOqvm1PEIAqFnqI/Hy2RYqrK7CtPmsNY=
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
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.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
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=
|
||||
@@ -7,17 +67,55 @@ 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/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
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/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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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=
|
||||
@@ -30,10 +128,18 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
|
||||
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=
|
||||
@@ -46,3 +152,11 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9x
|
||||
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=
|
||||
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
|
||||
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
.\" Automatically generated by Pandoc 3.8.2
|
||||
.\"
|
||||
.TH "CLINE" "1" "January 2025" "Cline CLI 1.0" "User Commands"
|
||||
.SH NAME
|
||||
cline \- orchestrate and interact with Cline AI coding agents
|
||||
.SH SYNOPSIS
|
||||
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
|
||||
.PP
|
||||
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]subcommand\f[R]]
|
||||
[\f[I]options\f[R]] [\f[I]arguments\f[R]]
|
||||
.SH DESCRIPTION
|
||||
Try: cat README.md | cline \(lqSummarize this for me:\(rq
|
||||
.PP
|
||||
\f[B]cline\f[R] is a command\-line interface for orchestrating multiple
|
||||
Cline AI coding agents.
|
||||
Cline is an autonomous AI agent who can read, write, and execute code
|
||||
across your projects.
|
||||
He operates through a client\-server architecture where \f[B]Cline
|
||||
Core\f[R] runs as a standalone service, and the CLI acts as a scriptable
|
||||
interface for managing tasks, instances, and agent interactions.
|
||||
.PP
|
||||
The CLI is designed for both interactive use and automation, making it
|
||||
ideal for CI/CD pipelines, parallel task execution, and terminal\-based
|
||||
workflows.
|
||||
Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline
|
||||
Core instance, enabling seamless task handoff between environments.
|
||||
.SH MODES OF OPERATION
|
||||
.TP
|
||||
\f[B]Instant Task Mode\f[R]
|
||||
The simplest invocation: \f[B]cline \(lqprompt here\(rq\f[R] immediately
|
||||
spawns an instance, creates a task, and enters chat mode.
|
||||
This is equivalent to running \f[B]cline instance new && cline task new
|
||||
&& cline task chat\f[R] in sequence.
|
||||
.TP
|
||||
\f[B]Subcommand Mode\f[R]
|
||||
Advanced usage with explicit control: \f[B]cline <command> [subcommand]
|
||||
[options]\f[R] provides fine\-grained control over instances, tasks,
|
||||
authentication, and configuration.
|
||||
.SH AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
.TP
|
||||
\f[B]ACT MODE\f[R]
|
||||
Cline actively uses tools to accomplish tasks.
|
||||
He can read files, write code, execute commands, use a headless browser,
|
||||
and more.
|
||||
This is the default mode for task execution.
|
||||
.TP
|
||||
\f[B]PLAN MODE\f[R]
|
||||
Cline gathers information and creates a detailed plan before
|
||||
implementation.
|
||||
He explores the codebase, asks clarifying questions, and presents a
|
||||
strategy for user approval before switching to ACT MODE.
|
||||
.SH INSTANT TASK OPTIONS
|
||||
When using the instant task syntax \f[B]cline \(lqprompt\(rq\f[R] the
|
||||
following options are available:
|
||||
.TP
|
||||
\f[B]\-o\f[R], \f[B]\-\-oneshot\f[R]
|
||||
Full autonomous mode.
|
||||
Cline completes the task and stops following after completion.
|
||||
Example: cline \-o \(lqwhat\(cqs 6 + 8?\(rq
|
||||
.TP
|
||||
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
|
||||
Override a setting for this task
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable fully autonomous mode.
|
||||
Disables all interactivity:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
ask_followup_question tool is disabled
|
||||
.IP \(bu 2
|
||||
attempt_completion happens automatically
|
||||
.IP \(bu 2
|
||||
execute_command runs in non\-blocking mode with timeout
|
||||
.IP \(bu 2
|
||||
PLAN MODE automatically switches to ACT MODE
|
||||
.RE
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Starting mode.
|
||||
Options: \f[B]act\f[R] (default), \f[B]plan\f[R]
|
||||
.SH GLOBAL OPTIONS
|
||||
These options apply to all subcommands:
|
||||
.TP
|
||||
\f[B]\-F\f[R], \f[B]\-\-output\-format\f[R] \f[I]format\f[R]
|
||||
Output format.
|
||||
Options: \f[B]rich\f[R] (default), \f[B]json\f[R], \f[B]plain\f[R]
|
||||
.TP
|
||||
\f[B]\-h\f[R], \f[B]\-\-help\f[R]
|
||||
Display help information for the command.
|
||||
.TP
|
||||
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R]
|
||||
Enable verbose output for debugging.
|
||||
.SH COMMANDS
|
||||
.SS Authentication
|
||||
\f[B]cline auth\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
|
||||
.TP
|
||||
\f[B]cline a\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
|
||||
Configure authentication for AI model providers.
|
||||
Launches an interactive wizard if no arguments provided.
|
||||
If provider is specified without a key, prompts for the key or launches
|
||||
the appropriate OAuth flow.
|
||||
.SS Instance Management
|
||||
Cline Core instances are independent agent processes that can run in the
|
||||
background.
|
||||
Multiple instances can run simultaneously, enabling parallel task
|
||||
execution.
|
||||
.PP
|
||||
\f[B]cline instance\f[R]
|
||||
.TP
|
||||
\f[B]cline i\f[R]
|
||||
Display instance management help.
|
||||
.PP
|
||||
\f[B]cline instance new\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
|
||||
.TP
|
||||
\f[B]cline i n\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
|
||||
Spawn a new Cline Core instance.
|
||||
Use \f[B]\-\-default\f[R] to set it as the default instance for
|
||||
subsequent commands.
|
||||
.PP
|
||||
\f[B]cline instance list\f[R]
|
||||
.TP
|
||||
\f[B]cline i l\f[R]
|
||||
List all running Cline Core instances with their addresses and status.
|
||||
.PP
|
||||
\f[B]cline instance default\f[R] \f[I]address\f[R]
|
||||
.TP
|
||||
\f[B]cline i d\f[R] \f[I]address\f[R]
|
||||
Set the default instance to avoid specifying \f[B]\-\-address\f[R] in
|
||||
task commands.
|
||||
.PP
|
||||
\f[B]cline instance kill\f[R] \f[I]address\f[R]
|
||||
[\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
|
||||
.TP
|
||||
\f[B]cline i k\f[R] \f[I]address\f[R] [\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
|
||||
Terminate a Cline Core instance.
|
||||
Use \f[B]\-\-all\f[R] to kill all running instances.
|
||||
.SS Task Management
|
||||
Tasks represent individual work items that Cline executes.
|
||||
Tasks maintain conversation history, checkpoints, and settings.
|
||||
.PP
|
||||
\f[B]cline task\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R]
|
||||
\f[I]ADDR\f[R]]
|
||||
.TP
|
||||
\f[B]cline t\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R] \f[I]ADDR\f[R]]
|
||||
Display task management help.
|
||||
The \f[B]\-\-address\f[R] flag specifies which Cline Core instance to
|
||||
use (e.g., localhost:50052).
|
||||
.PP
|
||||
\f[B]cline task new\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t n\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
|
||||
Create a new task in the default or specified instance.
|
||||
Options:
|
||||
.RS
|
||||
.TP
|
||||
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
|
||||
Set task\-specific settings
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable autonomous mode
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Starting mode (act or plan)
|
||||
.RE
|
||||
.PP
|
||||
\f[B]cline task open\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t o\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
|
||||
Resume a previous task from history.
|
||||
Accepts the same options as \f[B]task new\f[R].
|
||||
.PP
|
||||
\f[B]cline task list\f[R]
|
||||
.TP
|
||||
\f[B]cline t l\f[R]
|
||||
List all tasks in history with their id and snippet
|
||||
.PP
|
||||
\f[B]cline task chat\f[R]
|
||||
.TP
|
||||
\f[B]cline t c\f[R]
|
||||
Enter interactive chat mode for the current task.
|
||||
Allows back\-and\-forth conversation with Cline.
|
||||
.PP
|
||||
\f[B]cline task send\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
|
||||
.TP
|
||||
\f[B]cline t s\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
|
||||
Send a message to Cline.
|
||||
If no message is provided, reads from stdin.
|
||||
Options:
|
||||
.RS
|
||||
.TP
|
||||
\f[B]\-a\f[R], \f[B]\-\-approve\f[R]
|
||||
Approve Cline\(cqs proposed action
|
||||
.TP
|
||||
\f[B]\-d\f[R], \f[B]\-\-deny\f[R]
|
||||
Deny Cline\(cqs proposed action
|
||||
.TP
|
||||
\f[B]\-f\f[R], \f[B]\-\-file\f[R] \f[I]FILE\f[R]
|
||||
Attach a file to the message
|
||||
.TP
|
||||
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
|
||||
Enable autonomous mode
|
||||
.TP
|
||||
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
|
||||
Switch mode (act or plan)
|
||||
.RE
|
||||
.PP
|
||||
\f[B]cline task view\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]]
|
||||
[\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
|
||||
.TP
|
||||
\f[B]cline t v\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]] [\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
|
||||
Display the current conversation.
|
||||
Use \f[B]\-\-follow\f[R] to stream updates in real\-time, or
|
||||
\f[B]\-\-follow\-complete\f[R] to follow until task completion.
|
||||
.PP
|
||||
\f[B]cline task restore\f[R] \f[I]checkpoint\f[R]
|
||||
.TP
|
||||
\f[B]cline t r\f[R] \f[I]checkpoint\f[R]
|
||||
Restore the task to a previous checkpoint state.
|
||||
.PP
|
||||
\f[B]cline task pause\f[R]
|
||||
.TP
|
||||
\f[B]cline t p\f[R]
|
||||
Pause task execution.
|
||||
.SS Configuration
|
||||
Configuration can be set globally.
|
||||
Override these global settings for a task using the
|
||||
\f[B]\-\-setting\f[R] flag
|
||||
.PP
|
||||
\f[B]cline config\f[R]
|
||||
.PP
|
||||
\f[B]cline c\f[R]
|
||||
.PP
|
||||
\f[B]cline config set\f[R] \f[I]key\f[R] \f[I]value\f[R]
|
||||
.TP
|
||||
\f[B]cline c s\f[R] \f[I]key\f[R] \f[I]value\f[R]
|
||||
Set a configuration variable.
|
||||
.PP
|
||||
\f[B]cline config get\f[R] \f[I]key\f[R]
|
||||
.TP
|
||||
\f[B]cline c g\f[R] \f[I]key\f[R]
|
||||
Read a configuration variable.
|
||||
.PP
|
||||
\f[B]cline config list\f[R]
|
||||
.TP
|
||||
\f[B]cline c l\f[R]
|
||||
List all configuration variables and their values.
|
||||
.SH TASK SETTINGS
|
||||
Task settings are persisted in the \f[I]\(ti/.cline/x/tasks\f[R]
|
||||
directory.
|
||||
When resuming a task with \f[B]cline task open\f[R], task settings are
|
||||
automatically restored.
|
||||
.PP
|
||||
Common settings include:
|
||||
.TP
|
||||
\f[B]yolo\f[R]
|
||||
Enable autonomous mode (true/false)
|
||||
.TP
|
||||
\f[B]mode\f[R]
|
||||
Starting mode (act/plan)
|
||||
.SH NOTES & EXAMPLES
|
||||
The \f[B]cline task send\f[R] and \f[B]cline task new\f[R] commands
|
||||
support reading from stdin, enabling powerful pipeline compositions:
|
||||
.IP
|
||||
.EX
|
||||
cat requirements.txt \f[B]|\f[R] cline task send
|
||||
echo \(dqRefactor this code\(dq \f[B]|\f[R] cline \-y
|
||||
.EE
|
||||
.SS Instance Management
|
||||
Manage multiple Cline instances:
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Start a new instance and make it default\f[R]
|
||||
cline instance new \-\-default
|
||||
|
||||
\f[I]# List all running instances\f[R]
|
||||
cline instance list
|
||||
|
||||
\f[I]# Kill a specific instance\f[R]
|
||||
cline instance kill localhost:50052
|
||||
|
||||
\f[I]# Kill all CLI instances\f[R]
|
||||
cline instance kill \-\-all\-cli
|
||||
.EE
|
||||
.SS Task History
|
||||
Work with task history:
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# List previous tasks\f[R]
|
||||
cline task list
|
||||
|
||||
\f[I]# Resume a previous task\f[R]
|
||||
cline task open 1760501486669
|
||||
|
||||
\f[I]# View conversation history\f[R]
|
||||
cline task view
|
||||
|
||||
\f[I]# Start interactive chat with this task\f[R]
|
||||
cline task chat
|
||||
.EE
|
||||
.SH ARCHITECTURE
|
||||
Cline operates on a three\-layer architecture:
|
||||
.TP
|
||||
\f[B]Presentation Layer\f[R]
|
||||
User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via
|
||||
gRPC
|
||||
.TP
|
||||
\f[B]Cline Core\f[R]
|
||||
The autonomous agent service handling task management, AI model
|
||||
integration, state management, tool orchestration, and real\-time
|
||||
streaming updates
|
||||
.TP
|
||||
\f[B]Host Provider Layer\f[R]
|
||||
Environment\-specific integrations (VSCode APIs, JetBrains APIs, shell
|
||||
APIs) that Cline Core uses to interact with the host system
|
||||
.SH BUGS
|
||||
Report bugs at: \c
|
||||
.UR https://github.com/cline/cline/issues
|
||||
.UE \c
|
||||
.PP
|
||||
For real\-time help, join the Discord community at: \c
|
||||
.UR https://discord.gg/cline
|
||||
.UE \c
|
||||
.SH SEE ALSO
|
||||
Full documentation: \c
|
||||
.UR https://docs.cline.bot
|
||||
.UE \c
|
||||
.SH AUTHORS
|
||||
Cline is developed by the Cline Bot Inc.\ and the open source community.
|
||||
.SH COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
title: CLINE
|
||||
section: 1
|
||||
header: User Commands
|
||||
footer: Cline CLI 1.0
|
||||
date: January 2025
|
||||
---
|
||||
|
||||
# NAME
|
||||
|
||||
cline - orchestrate and interact with Cline AI coding agents
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
**cline** [*prompt*] [*options*]
|
||||
|
||||
**cline** *command* [*subcommand*] [*options*] [*arguments*]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Try: cat README.md | cline "Summarize this for me:"
|
||||
|
||||
**cline** is a command-line interface for orchestrating multiple Cline AI coding agents. Cline is an autonomous AI agent who can read, write, and execute code across your projects. He operates through a client-server architecture where **Cline Core** runs as a standalone service, and the CLI acts as a scriptable interface for managing tasks, instances, and agent interactions.
|
||||
|
||||
The CLI is designed for both interactive use and automation, making it ideal for CI/CD pipelines, parallel task execution, and terminal-based workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline Core instance, enabling seamless task handoff between environments.
|
||||
|
||||
# MODES OF OPERATION
|
||||
|
||||
**Instant Task Mode**
|
||||
|
||||
: The simplest invocation: **cline "prompt here"** immediately spawns an instance, creates a task, and enters chat mode. This is equivalent to running **cline instance new && cline task new && cline task chat** in sequence.
|
||||
|
||||
**Subcommand Mode**
|
||||
|
||||
: Advanced usage with explicit control: **cline \<command\> [subcommand] [options]** provides fine-grained control over instances, tasks, authentication, and configuration.
|
||||
|
||||
# AGENT BEHAVIOR
|
||||
|
||||
Cline operates in two primary modes:
|
||||
|
||||
**ACT MODE**
|
||||
|
||||
: Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
|
||||
|
||||
**PLAN MODE**
|
||||
|
||||
: Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
|
||||
|
||||
# INSTANT TASK OPTIONS
|
||||
|
||||
When using the instant task syntax **cline "prompt"** the following options are available:
|
||||
|
||||
**-o**, **\--oneshot**
|
||||
|
||||
: Full autonomous mode. Cline completes the task and stops following after completion. Example: cline -o "what's 6 + 8?"
|
||||
|
||||
**-s**, **\--setting** *setting* *value*
|
||||
|
||||
: Override a setting for this task
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
|
||||
: Enable fully autonomous mode. Disables all interactivity:
|
||||
- ask_followup_question tool is disabled
|
||||
- attempt_completion happens automatically
|
||||
- execute_command runs in non-blocking mode with timeout
|
||||
- PLAN MODE automatically switches to ACT MODE
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
|
||||
: Starting mode. Options: **act** (default), **plan**
|
||||
|
||||
# GLOBAL OPTIONS
|
||||
|
||||
These options apply to all subcommands:
|
||||
|
||||
**-F**, **\--output-format** *format*
|
||||
|
||||
: Output format. Options: **rich** (default), **json**, **plain**
|
||||
|
||||
**-h**, **\--help**
|
||||
|
||||
: Display help information for the command.
|
||||
|
||||
**-v**, **\--verbose**
|
||||
|
||||
: Enable verbose output for debugging.
|
||||
|
||||
# COMMANDS
|
||||
|
||||
## Authentication
|
||||
|
||||
**cline auth** [*provider*] [*key*]
|
||||
|
||||
**cline a** [*provider*] [*key*]
|
||||
|
||||
: Configure authentication for AI model providers. Launches an interactive wizard if no arguments provided. If provider is specified without a key, prompts for the key or launches the appropriate OAuth flow.
|
||||
|
||||
## Instance Management
|
||||
|
||||
Cline Core instances are independent agent processes that can run in the background. Multiple instances can run simultaneously, enabling parallel task execution.
|
||||
|
||||
**cline instance**
|
||||
|
||||
**cline i**
|
||||
|
||||
: Display instance management help.
|
||||
|
||||
**cline instance new** [**-d**|**\--default**]
|
||||
|
||||
**cline i n** [**-d**|**\--default**]
|
||||
|
||||
: Spawn a new Cline Core instance. Use **\--default** to set it as the default instance for subsequent commands.
|
||||
|
||||
**cline instance list**
|
||||
|
||||
**cline i l**
|
||||
|
||||
: List all running Cline Core instances with their addresses and status.
|
||||
|
||||
**cline instance default** *address*
|
||||
|
||||
**cline i d** *address*
|
||||
|
||||
: Set the default instance to avoid specifying **\--address** in task commands.
|
||||
|
||||
**cline instance kill** *address* [**-a**|**\--all**]
|
||||
|
||||
**cline i k** *address* [**-a**|**\--all**]
|
||||
|
||||
: Terminate a Cline Core instance. Use **\--all** to kill all running instances.
|
||||
|
||||
## Task Management
|
||||
|
||||
Tasks represent individual work items that Cline executes. Tasks maintain conversation history, checkpoints, and settings.
|
||||
|
||||
**cline task** [**-a**|**\--address** *ADDR*]
|
||||
|
||||
**cline t** [**-a**|**\--address** *ADDR*]
|
||||
|
||||
: Display task management help. The **\--address** flag specifies which Cline Core instance to use (e.g., localhost:50052).
|
||||
|
||||
**cline task new** *prompt* [*options*]
|
||||
|
||||
**cline t n** *prompt* [*options*]
|
||||
|
||||
: Create a new task in the default or specified instance. Options:
|
||||
|
||||
**-s**, **\--setting** *setting* *value*
|
||||
: Set task-specific settings
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
: Enable autonomous mode
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
: Starting mode (act or plan)
|
||||
|
||||
**cline task open** *task-id* [*options*]
|
||||
|
||||
**cline t o** *task-id* [*options*]
|
||||
|
||||
: Resume a previous task from history. Accepts the same options as **task new**.
|
||||
|
||||
**cline task list**
|
||||
|
||||
**cline t l**
|
||||
|
||||
: List all tasks in history with their id and snippet
|
||||
|
||||
**cline task chat**
|
||||
|
||||
**cline t c**
|
||||
|
||||
: Enter interactive chat mode for the current task. Allows back-and-forth conversation with Cline.
|
||||
|
||||
**cline task send** [*message*] [*options*]
|
||||
|
||||
**cline t s** [*message*] [*options*]
|
||||
|
||||
: Send a message to Cline. If no message is provided, reads from stdin. Options:
|
||||
|
||||
**-a**, **\--approve**
|
||||
: Approve Cline's proposed action
|
||||
|
||||
**-d**, **\--deny**
|
||||
: Deny Cline's proposed action
|
||||
|
||||
**-f**, **\--file** *FILE*
|
||||
: Attach a file to the message
|
||||
|
||||
**-y**, **\--no-interactive**, **\--yolo**
|
||||
: Enable autonomous mode
|
||||
|
||||
**-m**, **\--mode** *mode*
|
||||
: Switch mode (act or plan)
|
||||
|
||||
**cline task view** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
|
||||
|
||||
**cline t v** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
|
||||
|
||||
: Display the current conversation. Use **\--follow** to stream updates in real-time, or **\--follow-complete** to follow until task completion.
|
||||
|
||||
**cline task restore** *checkpoint*
|
||||
|
||||
**cline t r** *checkpoint*
|
||||
|
||||
: Restore the task to a previous checkpoint state.
|
||||
|
||||
**cline task pause**
|
||||
|
||||
**cline t p**
|
||||
|
||||
: Pause task execution.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration can be set globally. Override these global settings for a task using the **\--setting** flag
|
||||
|
||||
**cline config**
|
||||
|
||||
**cline c**
|
||||
|
||||
**cline config set** *key* *value*
|
||||
|
||||
**cline c s** *key* *value*
|
||||
|
||||
: Set a configuration variable.
|
||||
|
||||
**cline config get** *key*
|
||||
|
||||
**cline c g** *key*
|
||||
|
||||
: Read a configuration variable.
|
||||
|
||||
**cline config list**
|
||||
|
||||
**cline c l**
|
||||
|
||||
: List all configuration variables and their values.
|
||||
|
||||
# TASK SETTINGS
|
||||
|
||||
Task settings are persisted in the *~/.cline/x/tasks* directory. When resuming a task with **cline task open**, task settings are automatically restored.
|
||||
|
||||
Common settings include:
|
||||
|
||||
**yolo**
|
||||
|
||||
: Enable autonomous mode (true/false)
|
||||
|
||||
**mode**
|
||||
|
||||
: Starting mode (act/plan)
|
||||
|
||||
# NOTES & EXAMPLES
|
||||
|
||||
The **cline task send** and **cline task new** commands support reading from stdin, enabling powerful pipeline compositions:
|
||||
|
||||
```bash
|
||||
cat requirements.txt | cline task send
|
||||
echo "Refactor this code" | cline -y
|
||||
```
|
||||
|
||||
## Instance Management
|
||||
|
||||
Manage multiple Cline instances:
|
||||
|
||||
```bash
|
||||
# Start a new instance and make it default
|
||||
cline instance new --default
|
||||
|
||||
# List all running instances
|
||||
cline instance list
|
||||
|
||||
# Kill a specific instance
|
||||
cline instance kill localhost:50052
|
||||
|
||||
# Kill all CLI instances
|
||||
cline instance kill --all-cli
|
||||
```
|
||||
|
||||
## Task History
|
||||
|
||||
Work with task history:
|
||||
|
||||
```bash
|
||||
# List previous tasks
|
||||
cline task list
|
||||
|
||||
# Resume a previous task
|
||||
cline task open 1760501486669
|
||||
|
||||
# View conversation history
|
||||
cline task view
|
||||
|
||||
# Start interactive chat with this task
|
||||
cline task chat
|
||||
```
|
||||
|
||||
# ARCHITECTURE
|
||||
|
||||
Cline operates on a three-layer architecture:
|
||||
|
||||
**Presentation Layer**
|
||||
|
||||
: User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via gRPC
|
||||
|
||||
**Cline Core**
|
||||
|
||||
: The autonomous agent service handling task management, AI model integration, state management, tool orchestration, and real-time streaming updates
|
||||
|
||||
**Host Provider Layer**
|
||||
|
||||
: Environment-specific integrations (VSCode APIs, JetBrains APIs, shell APIs) that Cline Core uses to interact with the host system
|
||||
|
||||
# BUGS
|
||||
|
||||
Report bugs at: <https://github.com/cline/cline/issues>
|
||||
|
||||
For real-time help, join the Discord community at: <https://discord.gg/cline>
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Cline is developed by the Cline Bot Inc. and the open source community.
|
||||
|
||||
# COPYRIGHT
|
||||
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.0-nightly.18",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"bin": {
|
||||
"cline": "./bin/cline",
|
||||
"cline-host": "./bin/cline-host"
|
||||
},
|
||||
"man": "./man/cline.1",
|
||||
"scripts": {
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@grpc/grpc-js",
|
||||
"@grpc/reflection",
|
||||
"better-sqlite3",
|
||||
"grpc-health-check",
|
||||
"open",
|
||||
"vscode-uri"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"open": "^10.1.2",
|
||||
"vscode-uri": "^3.1.0"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
+31
-113
@@ -1,125 +1,43 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
Short: "Authenticate a provider and configure what model is used",
|
||||
Long: `Authenticate a provider and configure what model is used
|
||||
|
||||
Interactive Mode:
|
||||
Run without flags to open an interactive menu where you can:
|
||||
- Sign in to your Cline account
|
||||
- Configure other LLM providers (Anthropic, OpenAI, etc.)
|
||||
- Select and switch between AI models
|
||||
- Manage provider settings
|
||||
|
||||
Quick Setup Mode:
|
||||
Use flags to quickly configure a BYO provider non-interactively:
|
||||
|
||||
Examples:
|
||||
cline auth --provider openai-native --apikey sk-xxx --modelid gpt-5
|
||||
cline auth -p anthropic -k sk-ant-xxx -m claude-sonnet-4-5-20250929
|
||||
cline auth -p openai-compatible -k xxx -m gpt-4 -b https://api.example.com/v1
|
||||
|
||||
Supported providers: openai-native, openai, anthropic, gemini, openrouter, xai, cerebras, ollama
|
||||
Note: Bedrock provider requires interactive setup due to complex auth fields`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleAuthCommand(cmd.Context())
|
||||
return auth.RunAuthFlow(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthCommand(ctx context.Context) error {
|
||||
fmt.Print("Authenticating with Cline...\n")
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("You are signed in!")
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||
if response == "y" || response == "yes" {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to login: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
return true
|
||||
}
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// Add flags for quick setup mode
|
||||
cmd.Flags().StringVarP(&auth.QuickProvider, "provider", "p", "", "Provider ID for quick setup (e.g., openai-native, anthropic)")
|
||||
cmd.Flags().StringVarP(&auth.QuickAPIKey, "apikey", "k", "", "API key for the provider")
|
||||
cmd.Flags().StringVarP(&auth.QuickModelID, "modelid", "m", "", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
|
||||
cmd.Flags().StringVarP(&auth.QuickBaseURL, "baseurl", "b", "", "Base URL (optional, only for openai provider)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
}
|
||||
|
||||
response, 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...")
|
||||
if response != nil && response.Value != "" {
|
||||
fmt.Printf(" If the browser doesn't open automatically, visit this URL:\n %s\n\n", response.Value)
|
||||
}
|
||||
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,323 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// contextKey is a distinct type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const authInstanceAddressKey contextKey = "authInstanceAddress"
|
||||
|
||||
// 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 BYO API providers - always shown. Launches provider setup wizard
|
||||
// ┃ Exit authorization wizard - always shown. Exits the auth menu
|
||||
|
||||
// RunAuthFlow is the entry point for the entire auth flow with instance management
|
||||
// It spawns a fresh instance for auth operations and cleans it up when done
|
||||
func RunAuthFlow(ctx context.Context, args []string) error {
|
||||
// Spawn a fresh instance for auth operations
|
||||
instanceInfo, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start auth instance: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup when done (success, error, or panic)
|
||||
defer func() {
|
||||
verboseLog("Shutting down auth instance at %s", instanceInfo.Address)
|
||||
if err := global.KillInstanceByAddress(context.Background(), global.Clients.GetRegistry(), instanceInfo.Address); err != nil {
|
||||
verboseLog("Warning: Failed to kill auth instance: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Store instance address in context for all auth handlers to use
|
||||
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.Address)
|
||||
|
||||
// Route to existing auth flow
|
||||
return HandleAuthCommand(authCtx, args)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
// Check if flags are provided for quick setup
|
||||
if QuickProvider != "" || QuickAPIKey != "" || QuickModelID != "" || QuickBaseURL != "" {
|
||||
if QuickProvider == "" || QuickAPIKey == "" || QuickModelID == "" {
|
||||
return fmt.Errorf("quick setup requires --provider, --apikey, and --modelid flags. Use 'cline auth --help' for more information")
|
||||
}
|
||||
return QuickSetupFromFlags(ctx, QuickProvider, QuickAPIKey, QuickModelID, QuickBaseURL)
|
||||
}
|
||||
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// No args: Show uth wizard
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
case 1, 2, 3, 4:
|
||||
fmt.Println("Invalid positional arguments. Correct usage:")
|
||||
fmt.Println(" cline auth --provider <provider> --apikey <key> --modelid <model> --baseurl <optional>")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("too many arguments. Use flags for quick setup: --provider, --apikey, --modelid --baseurl(optional)")
|
||||
}
|
||||
}
|
||||
|
||||
// getAuthInstanceAddress retrieves the auth instance address from context
|
||||
// Returns empty string if not found (falls back to default behavior)
|
||||
func getAuthInstanceAddress(ctx context.Context) string {
|
||||
if addr, ok := ctx.Value(authInstanceAddressKey).(string); ok {
|
||||
return addr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleAuthMenuNoArgs prepares the auth menu when no arguments are provided
|
||||
func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
// 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 {
|
||||
// Check if user cancelled - propagate for clean exit
|
||||
if err == huh.ErrUserAborted {
|
||||
return huh.ErrUserAborted
|
||||
}
|
||||
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 BYO API providers", 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 BYO API providers", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
}
|
||||
}
|
||||
|
||||
// Determine menu title based on status
|
||||
var title string
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
|
||||
// Always show Cline authentication status
|
||||
if isClineAuthenticated {
|
||||
title = fmt.Sprintf("Cline Account: %s Authenticated\n", renderer.Green("✓"))
|
||||
} else {
|
||||
title = fmt.Sprintf("Cline Account: %s Not authenticated\n", renderer.Red("✗"))
|
||||
}
|
||||
|
||||
// Show active provider and model if configured (regardless of Cline auth status)
|
||||
if currentProvider != "" && currentModel != "" {
|
||||
title += fmt.Sprintf("Active Provider: %s\nActive Model: %s\n",
|
||||
renderer.White(currentProvider),
|
||||
renderer.White(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 {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
// Return the error to allow deferred cleanup to run
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
return huh.ErrUserAborted
|
||||
}
|
||||
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)
|
||||
// Uses the auth instance address from context if available, otherwise falls back to default
|
||||
func createTaskManager(ctx context.Context) (*task.Manager, error) {
|
||||
authAddr := getAuthInstanceAddress(ctx)
|
||||
if authAddr != "" {
|
||||
return task.NewManagerForAddress(ctx, authAddr)
|
||||
}
|
||||
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,240 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Package-level variables for command-line flags
|
||||
var (
|
||||
QuickProvider string // Provider ID (e.g., "openai", "anthropic")
|
||||
QuickAPIKey string // API key for the provider
|
||||
QuickModelID string // Model ID to configure
|
||||
QuickBaseURL string // Base URL (optional, for openai compatible only)
|
||||
)
|
||||
|
||||
// QuickSetupFromFlags performs quick setup using command-line flags
|
||||
// Returns error if validation fails or configuration cannot be applied
|
||||
func QuickSetupFromFlags(ctx context.Context, provider, apiKey, modelID, baseURL string) error {
|
||||
// Validate all input parameters
|
||||
providerEnum, err := validateQuickSetupInputs(provider, apiKey, modelID, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create task manager for state operations
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Validate and fetch model information if needed
|
||||
finalModelID, modelInfo, err := validateAndFetchModel(ctx, manager, providerEnum, modelID, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model validation failed: %w", err)
|
||||
}
|
||||
|
||||
// For Ollama, baseURL is stored in the API key field
|
||||
finalAPIKey := apiKey
|
||||
finalBaseURL := baseURL
|
||||
if providerEnum == cline.ApiProvider_OLLAMA {
|
||||
if baseURL != "" {
|
||||
finalAPIKey = baseURL
|
||||
finalBaseURL = ""
|
||||
} else if apiKey != "" {
|
||||
// User provided API key for Ollama - treat it as baseURL
|
||||
finalAPIKey = apiKey
|
||||
finalBaseURL = ""
|
||||
} else {
|
||||
// Use default Ollama baseURL
|
||||
finalAPIKey = "http://localhost:11434"
|
||||
finalBaseURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the provider using existing AddProviderPartial function
|
||||
if err := AddProviderPartial(ctx, manager, providerEnum, finalModelID, finalAPIKey, finalBaseURL, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to configure provider: %w", err)
|
||||
}
|
||||
|
||||
// Set the provider as active for both Plan and Act modes
|
||||
if err := UpdateProviderPartial(ctx, manager, providerEnum, ProviderUpdatesPartial{}, true); err != nil {
|
||||
return fmt.Errorf("failed to set provider as active: %w", err)
|
||||
}
|
||||
|
||||
// Mark welcome view as completed
|
||||
if err := markWelcomeViewCompleted(ctx, manager); err != nil {
|
||||
// Non-fatal error, just log it
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Warning: failed to mark welcome view as completed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Success message
|
||||
fmt.Printf("\n✓ Successfully configured %s provider\n", GetProviderDisplayName(providerEnum))
|
||||
fmt.Printf(" Model: %s\n", finalModelID)
|
||||
if providerEnum == cline.ApiProvider_OLLAMA {
|
||||
fmt.Printf(" Base URL: %s\n", finalAPIKey)
|
||||
} else {
|
||||
fmt.Println(" API Key: Configured")
|
||||
}
|
||||
if finalBaseURL != "" {
|
||||
fmt.Printf(" Custom Base URL: %s\n", finalBaseURL)
|
||||
}
|
||||
fmt.Println("\nYou can now use Cline with this provider.")
|
||||
fmt.Println("Run 'cline start' to begin a new task.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateQuickSetupInputs validates all input parameters for quick setup
|
||||
// Returns the validated provider enum or an error if validation fails
|
||||
func validateQuickSetupInputs(provider, apiKey, modelID, baseURL string) (cline.ApiProvider, error) {
|
||||
// Validate required parameters
|
||||
if provider == "" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("provider is required. Use --provider or -p flag")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(apiKey) == "" && provider != "ollama" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("API key is required for %s provider. Use --apikey or -k flag", provider)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(modelID) == "" {
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("model ID is required. Use --modelid or -m flag")
|
||||
}
|
||||
|
||||
// Validate and map provider string to enum
|
||||
providerEnum, err := validateQuickSetupProvider(provider)
|
||||
if err != nil {
|
||||
return cline.ApiProvider_ANTHROPIC, err
|
||||
}
|
||||
|
||||
// Validate that baseURL is only provided for OpenAI-compatible providers
|
||||
if err := validateBaseURL(baseURL, providerEnum); err != nil {
|
||||
return cline.ApiProvider_ANTHROPIC, err
|
||||
}
|
||||
|
||||
return providerEnum, nil
|
||||
}
|
||||
|
||||
// validateBaseURL checks if the user's input includes a baseURL for a provider other than OpenAI (compatible)
|
||||
// Returns error if baseURL is provided for unsupported providers
|
||||
func validateBaseURL(baseURL string, providerEnum cline.ApiProvider) error {
|
||||
if providerEnum != cline.ApiProvider_OPENAI {
|
||||
if baseURL != "" {
|
||||
return fmt.Errorf("base URL is only supported for OpenAI and OpenAI-compatible providers")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// validateQuickSetupProvider validates the provider ID and returns the enum value
|
||||
// Returns error if provider is invalid or not supported for quick setup
|
||||
func validateQuickSetupProvider(providerID string) (cline.ApiProvider, error) {
|
||||
// Normalize provider ID (trim whitespace, lowercase)
|
||||
normalizedID := strings.TrimSpace(strings.ToLower(providerID))
|
||||
|
||||
// Explicitly block Bedrock
|
||||
if normalizedID == "bedrock" {
|
||||
return cline.ApiProvider_BEDROCK, fmt.Errorf("bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup: cline auth")
|
||||
}
|
||||
|
||||
// Map provider string to enum using existing function
|
||||
provider, ok := mapProviderStringToEnum(normalizedID)
|
||||
if !ok {
|
||||
// Provider not found - provide helpful error message
|
||||
supportedProviders := []string{
|
||||
"openai-native", "openai", "anthropic", "gemini",
|
||||
"openrouter", "xai", "cerebras", "ollama",
|
||||
}
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf(
|
||||
"invalid provider '%s'. Supported providers: %s",
|
||||
providerID,
|
||||
strings.Join(supportedProviders, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
// Validate against supported quick setup providers
|
||||
supportedProviders := map[cline.ApiProvider]bool{
|
||||
cline.ApiProvider_OPENAI_NATIVE: true,
|
||||
cline.ApiProvider_OPENAI: true,
|
||||
cline.ApiProvider_ANTHROPIC: true,
|
||||
cline.ApiProvider_GEMINI: true,
|
||||
cline.ApiProvider_OPENROUTER: true,
|
||||
cline.ApiProvider_XAI: true,
|
||||
cline.ApiProvider_CEREBRAS: true,
|
||||
cline.ApiProvider_OLLAMA: true,
|
||||
}
|
||||
|
||||
if !supportedProviders[provider] {
|
||||
return provider, fmt.Errorf(
|
||||
"provider '%s' is not supported for quick setup. Please use interactive setup: cline auth",
|
||||
providerID,
|
||||
)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// validateAndFetchModel validates the model ID or fetches from provider if needed
|
||||
// Returns the final model ID and optional model info
|
||||
// For providers with static models, validates against the list
|
||||
// For providers with dynamic models, fetches the list if possible
|
||||
func validateAndFetchModel(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID, apiKey string) (string, interface{}, error) {
|
||||
// Normalize model ID
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return "", nil, fmt.Errorf("model ID cannot be empty")
|
||||
}
|
||||
|
||||
// For most providers, we trust the user's input since we can't easily validate without making API calls
|
||||
// The actual validation will happen when the model is used
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
// OpenRouter supports model info fetching, but it requires an API call
|
||||
// For quick setup, we'll trust the user's input and return nil for model info
|
||||
// The actual model info will be fetched when needed
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] OpenRouter model ID: %s (will be validated on first use)\n", modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
// Ollama models can be validated by fetching the list, but this requires the server to be running
|
||||
// For quick setup, we'll trust the user's input
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Ollama model ID: %s (will be validated when server is accessible)\n", modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
|
||||
default:
|
||||
// For other providers (Anthropic, OpenAI, Gemini, XAI, Cerebras), trust user input
|
||||
// Model validation will occur when the model is actually used
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] %s model ID: %s (will be validated on first use)\n", GetProviderDisplayName(provider), modelID)
|
||||
}
|
||||
return modelID, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// markWelcomeViewCompleted marks the welcome view as completed in the state
|
||||
// This prevents the welcome view from showing up after quick setup
|
||||
func markWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
|
||||
// Use the State service to update the welcome view flag
|
||||
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark welcome view as completed: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Marked welcome view as completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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,156 @@
|
||||
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.RefreshOpenRouterModelsRpc(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// FetchOcaModels fetches available Oca models from Cline Core
|
||||
func FetchOcaModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OcaModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOcaModels(ctx, &cline.StringRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Oca models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ConvertOcaModelsToInterface converts Oca model map to generic interface map.
|
||||
// This allows Oca and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOcaModelsToInterface(models map[string]*cline.OcaModelInfo) 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,181 @@
|
||||
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 Compatible", Provider: cline.ApiProvider_OPENAI},
|
||||
{Name: "OpenAI (Official)", 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},
|
||||
{Name: "Oracle Code Assist", Provider: cline.ApiProvider_OCA},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
case cline.ApiProvider_OCA:
|
||||
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., openai/gpt-oss-120b"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "e.g., gpt-5-2025-08-07"
|
||||
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"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "e.g., oca/llama4"
|
||||
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 (Compatible) provider, also prompts for an optional base URL.
|
||||
func PromptForAPIKey(provider cline.ApiProvider) (string, 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 (Compatible) provider, prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI {
|
||||
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)
|
||||
}
|
||||
|
||||
return apiKey, baseURL, nil
|
||||
}
|
||||
|
||||
return apiKey, "", nil
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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,
|
||||
cline.ApiProvider_OCA,
|
||||
}
|
||||
|
||||
// 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 a model configured
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
|
||||
// Determine if credentials exist
|
||||
hasCreds := checkAPIKeyExists(r.apiConfig, provider)
|
||||
|
||||
// Determine readiness: OCA uses auth state presence; others need creds and model
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
state, _ := GetLatestOCAState(context.Background(), 2 *time.Second)
|
||||
if state == nil || state.User == nil {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Provider is not ready unless it has credentials AND a model configured
|
||||
if !hasCreds || 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: checkAPIKeyExists(r.apiConfig, provider),
|
||||
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-compatible": // internal name is 'openai', but this is actually the openai-compatible provider
|
||||
return cline.ApiProvider_OPENAI, true
|
||||
case "openai", "openai-native": // This is the native, official Open AI provider
|
||||
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
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, 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-compatible"
|
||||
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"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "oca"
|
||||
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 Compatible"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "OpenAI (Official)"
|
||||
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)"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "Oracle Code Assist"
|
||||
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 || display.Provider == cline.ApiProvider_OCA {
|
||||
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 OCA provider via global auth subscription (state presence)
|
||||
if state, _ := GetLatestOCAState(context.Background(), 2*time.Second); state != nil && state.User != nil {
|
||||
configuredProviders = append(configuredProviders, cline.ApiProvider_OCA)
|
||||
verboseLog("[DEBUG] OCA provider has active auth state")
|
||||
}
|
||||
|
||||
// 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,620 @@
|
||||
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")
|
||||
BaseURLField string // Base URL field name (optional, empty if not applicable)
|
||||
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",
|
||||
BaseURLField: "openAiBaseUrl",
|
||||
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
|
||||
|
||||
case cline.ApiProvider_OCA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ocaApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOcaModelInfo",
|
||||
ActModeModelInfoField: "actModeOcaModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOcaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOcaModelId",
|
||||
}, 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)
|
||||
BaseURL *string // New base URL (optional, e.g., for OCA, Ollama)
|
||||
RefreshToken *string // New refresh token (optional, e.g., for OCA)
|
||||
Mode *string // New mode (optional, e.g., "internal" or "external" for OCA)
|
||||
}
|
||||
|
||||
// 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, includeBaseURL 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 base URL field if requested and applicable
|
||||
if includeBaseURL && fields.BaseURLField != "" {
|
||||
fieldPaths = append(fieldPaths, fields.BaseURLField)
|
||||
}
|
||||
|
||||
// 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
|
||||
case "ocaApiKey":
|
||||
apiConfig.OcaApiKey = 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
|
||||
case "planModeOcaModelId":
|
||||
apiConfig.PlanModeOcaModelId = value
|
||||
apiConfig.ActModeOcaModelId = 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, baseURL 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 base URL field if provided and applicable
|
||||
includeBaseURL := false
|
||||
if baseURL != "" && fields.BaseURLField != "" {
|
||||
setBaseURLField(apiConfig, fields.BaseURLField, proto.String(baseURL))
|
||||
includeBaseURL = true
|
||||
}
|
||||
|
||||
// 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, includeBaseURL, 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, false, 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
|
||||
}
|
||||
|
||||
// setBaseURLField sets the appropriate base URL field in the config based on the field name
|
||||
func setBaseURLField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaBaseUrl":
|
||||
apiConfig.OcaBaseUrl = value
|
||||
case "ollamaBaseUrl":
|
||||
apiConfig.OllamaBaseUrl = value
|
||||
case "openAiBaseUrl":
|
||||
apiConfig.OpenAiBaseUrl = value
|
||||
case "geminiBaseUrl":
|
||||
apiConfig.GeminiBaseUrl = value
|
||||
case "liteLlmBaseUrl":
|
||||
apiConfig.LiteLlmBaseUrl = value
|
||||
case "anthropicBaseUrl":
|
||||
apiConfig.AnthropicBaseUrl = value
|
||||
case "requestyBaseUrl":
|
||||
apiConfig.RequestyBaseUrl = value
|
||||
case "lmStudioBaseUrl":
|
||||
apiConfig.LmStudioBaseUrl = value
|
||||
case "oca":
|
||||
apiConfig.OcaBaseUrl = value
|
||||
}
|
||||
}
|
||||
|
||||
// setRefreshTokenField sets the appropriate refresh token field in the config
|
||||
func setRefreshTokenField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaRefreshToken":
|
||||
apiConfig.OcaRefreshToken = value
|
||||
}
|
||||
}
|
||||
|
||||
// setModeField sets the appropriate mode field in the config
|
||||
func setModeField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "ocaMode":
|
||||
apiConfig.OcaMode = value
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// OcaOptionalFields holds optional configuration fields for Oracle Code Assist
|
||||
type OcaOptionalFields struct {
|
||||
BaseURL *string // Optional: Base URL
|
||||
Mode *string // Optional: Mode ("internal" or "external")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// setOcaOptionalFields sets optional Oca-specific fields in the API configuration
|
||||
func setOcaOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *OcaOptionalFields) {
|
||||
if fields == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fields.Mode != nil {
|
||||
apiConfig.OcaMode = fields.Mode
|
||||
}
|
||||
if fields.BaseURL != nil {
|
||||
apiConfig.OcaBaseUrl = fields.BaseURL
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// buildOcaOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
|
||||
func buildOcaOptionalFieldMask(fields *OcaOptionalFields) []string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldPaths []string
|
||||
|
||||
if fields.Mode != nil {
|
||||
fieldPaths = append(fieldPaths, "ocaMode")
|
||||
}
|
||||
if fields.BaseURL != nil {
|
||||
fieldPaths = append(fieldPaths, "ocaBaseUrl")
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// Create task manager using auth instance from context
|
||||
manager, err := createTaskManager(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("Add or change an API 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 2b: Special handling for OCA provider
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
return pw.handleAddOcaProvider()
|
||||
}
|
||||
|
||||
// Step 3: Get API key first (for non-Bedrock providers)
|
||||
apiKey, baseURL, 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, baseURL, 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
|
||||
}
|
||||
|
||||
// handleAddOcaProvider handles adding Oracle Code Assist provider with optional settings and auth
|
||||
func (pw *ProviderWizard) handleAddOcaProvider() error {
|
||||
// Step 1: Get OCA configuration (base URL and mode)
|
||||
config, err := PromptForOcaConfig(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user aborted") || strings.Contains(err.Error(), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Apply OCA configuration (base URL and mode)
|
||||
if err := ApplyOcaConfig(pw.ctx, pw.manager, config); err != nil {
|
||||
return fmt.Errorf("failed to save OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Ensure OCA authentication
|
||||
if err := ensureOcaAuthenticated(pw.ctx); err != nil {
|
||||
return fmt.Errorf("failed to authenticate with OCA: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Select model
|
||||
modelID, _, err := pw.selectModel(cline.ApiProvider_OCA, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Apply the OCA model configuration and set as active
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: nil,
|
||||
}
|
||||
|
||||
if err := UpdateProviderPartial(pw.ctx, pw.manager, cline.ApiProvider_OCA, updates, true); err != nil {
|
||||
return fmt.Errorf("failed to save OCA 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("✓ OCA 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
|
||||
|
||||
case cline.ApiProvider_OCA:
|
||||
// OCA supports dynamic model fetching
|
||||
models, err := FetchOcaModels(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
interfaceMap := ConvertOcaModelsToInterface(models)
|
||||
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, 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 {
|
||||
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
|
||||
// Return a sentinel non-empty string so upstream checks pass.
|
||||
return "OCA_AUTH_VERIFIED"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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: If removing OCA, sign out first
|
||||
if selectedProvider.Provider == cline.ApiProvider_OCA {
|
||||
if err := signOutOca(pw.ctx); err != nil {
|
||||
fmt.Printf("Warning: Failed to sign out of OCA: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Signed out of OCA.")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: 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 signOutOca(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Ocaaccount.OcaAccountLogoutClicked(ctx, &cline.EmptyRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
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,366 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// OcaConfig holds Oracle Code Assist (OCA) configuration fields
|
||||
type OcaConfig struct {
|
||||
BaseURL string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// PromptForOcaConfig displays a form for OCA configuration (base URL and mode)
|
||||
func PromptForOcaConfig(ctx context.Context, manager *task.Manager) (*OcaConfig, error) {
|
||||
config := &OcaConfig{}
|
||||
var mode string
|
||||
|
||||
// Collect optional settings
|
||||
configForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Base URL").
|
||||
Value(&config.BaseURL).
|
||||
Description("Leave empty to use default Base URL"),
|
||||
|
||||
huh.NewSelect[string]().
|
||||
Title("Choose OCA mode (used for authentication)").
|
||||
Description("Select 'Internal' to use Cline's internal OCA, or 'External' for your own OCA instance").
|
||||
Options(
|
||||
huh.NewOption("Internal", "internal"),
|
||||
huh.NewOption("External", "external"),
|
||||
).
|
||||
Value(&mode),
|
||||
),
|
||||
)
|
||||
|
||||
if err := configForm.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields
|
||||
config.BaseURL = strings.TrimSpace(config.BaseURL)
|
||||
config.Mode = strings.TrimSpace(mode)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ApplyOcaConfig applies OCA configuration using partial updates
|
||||
func ApplyOcaConfig(ctx context.Context, manager *task.Manager, config *OcaConfig) error {
|
||||
// Build the API configuration with all OCA fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set profile authentication fields (always required)
|
||||
optionalFields := &OcaOptionalFields{}
|
||||
|
||||
// Set profile name (can be empty for default profile)
|
||||
if config.BaseURL != "" {
|
||||
optionalFields.BaseURL = proto.String(config.BaseURL)
|
||||
}
|
||||
|
||||
// Set optional fields if provided
|
||||
if config.Mode != "" {
|
||||
optionalFields.Mode = proto.String(config.Mode)
|
||||
}
|
||||
|
||||
// Apply all fields to the config
|
||||
setOcaOptionalFields(apiConfig, optionalFields)
|
||||
|
||||
// Add profile authentication field paths
|
||||
optionalPaths := buildOcaOptionalFieldMask(optionalFields)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: optionalPaths}
|
||||
|
||||
// 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 OCA configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// OCA Auth Listener Singleton
|
||||
// ===========================
|
||||
|
||||
type ocaAuthStream interface {
|
||||
Recv() (*cline.OcaAuthState, error)
|
||||
}
|
||||
|
||||
// OcaAuthStatusListener manages subscription to OCA auth status updates
|
||||
type OcaAuthStatusListener struct {
|
||||
stream ocaAuthStream
|
||||
updatesCh chan *cline.OcaAuthState
|
||||
errCh chan error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
lastState *cline.OcaAuthState
|
||||
firstEventCh chan struct{}
|
||||
firstEventOnce sync.Once
|
||||
}
|
||||
|
||||
// NewOcaAuthStatusListener creates a new OCA auth status listener
|
||||
func NewOcaAuthStatusListener(parentCtx context.Context) (*OcaAuthStatusListener, error) {
|
||||
client, err := global.GetDefaultClient(parentCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Keep the listener alive independently of short-lived caller contexts
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Subscribe to OCA auth status updates
|
||||
stream, err := client.Ocaaccount.OcaSubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to subscribe to OCA auth updates: %w", err)
|
||||
}
|
||||
|
||||
return &OcaAuthStatusListener{
|
||||
stream: stream,
|
||||
updatesCh: make(chan *cline.OcaAuthState, 10),
|
||||
errCh: make(chan error, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
firstEventCh: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening to the auth status update stream
|
||||
func (l *OcaAuthStatusListener) Start() error {
|
||||
go l.readStream()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *OcaAuthStatusListener) readStream() {
|
||||
defer close(l.updatesCh)
|
||||
defer close(l.errCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
default:
|
||||
state, err := l.stream.Recv()
|
||||
if err != nil {
|
||||
// Propagate error and exit
|
||||
if err == io.EOF {
|
||||
// Treat as error to notify waiters
|
||||
err = fmt.Errorf("OCA auth status stream closed")
|
||||
}
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
case <-l.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.lastState = state
|
||||
l.mu.Unlock()
|
||||
|
||||
// Notify first event waiters
|
||||
l.firstEventOnce.Do(func() { close(l.firstEventCh) })
|
||||
|
||||
select {
|
||||
case l.updatesCh <- state:
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForFirstEvent blocks until the first event is received or timeout occurs
|
||||
func (l *OcaAuthStatusListener) WaitForFirstEvent(timeout time.Duration) error {
|
||||
// Fast-path if already have a state
|
||||
l.mu.RLock()
|
||||
ready := l.lastState != nil
|
||||
l.mu.RUnlock()
|
||||
if ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-l.firstEventCh:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("timeout waiting for initial OCA auth event")
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("OCA auth listener cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticated returns true if the last known OCA auth state is authenticated
|
||||
func (l *OcaAuthStatusListener) IsAuthenticated() bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return isOCAStateAuthenticated(l.lastState)
|
||||
}
|
||||
|
||||
// WaitForAuthentication waits until OCA authentication succeeds or timeout occurs
|
||||
func (l *OcaAuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
// If already authenticated, return immediately
|
||||
if l.IsAuthenticated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("OCA authentication timeout after %v - please try again", timeout)
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("OCA authentication cancelled")
|
||||
case err := <-l.errCh:
|
||||
return fmt.Errorf("OCA authentication stream error: %w", err)
|
||||
case state := <-l.updatesCh:
|
||||
if isOCAStateAuthenticated(state) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop closes the stream and cleans up resources
|
||||
func (l *OcaAuthStatusListener) Stop() {
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
func isOCAStateAuthenticated(state *cline.OcaAuthState) bool {
|
||||
return state != nil && state.User != nil
|
||||
}
|
||||
|
||||
// Singleton holder
|
||||
var (
|
||||
ocaListener *OcaAuthStatusListener
|
||||
ocaListenerOnce sync.Once
|
||||
ocaListenerErr error
|
||||
)
|
||||
|
||||
// GetOcaAuthListener returns the OCA auth listener singleton
|
||||
func GetOcaAuthListener(ctx context.Context) (*OcaAuthStatusListener, error) {
|
||||
// Allow optional ctx: if nil, use context.TODO(). If already initialized, return singleton.
|
||||
if ctx == nil {
|
||||
ctx = context.TODO()
|
||||
}
|
||||
|
||||
ocaListenerOnce.Do(func() {
|
||||
l, err := NewOcaAuthStatusListener(ctx)
|
||||
if err != nil {
|
||||
ocaListenerErr = err
|
||||
return
|
||||
}
|
||||
if err := l.Start(); err != nil {
|
||||
ocaListenerErr = err
|
||||
return
|
||||
}
|
||||
ocaListener = l
|
||||
})
|
||||
return ocaListener, ocaListenerErr
|
||||
}
|
||||
|
||||
// IsOCAAuthenticated returns true if the global OCA auth status is authenticated.
|
||||
// It attempts a brief wait for the first event to avoid stale reads.
|
||||
func IsOCAAuthenticated(ctx context.Context) bool {
|
||||
l, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = l.WaitForFirstEvent(1 * time.Second) // best-effort
|
||||
return l.IsAuthenticated()
|
||||
}
|
||||
|
||||
// LatestState returns the last received OCA auth state (may be nil)
|
||||
func (l *OcaAuthStatusListener) LatestState() *cline.OcaAuthState {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.lastState
|
||||
}
|
||||
|
||||
// GetLatestOCAState returns the latest known OCA auth state, optionally waiting for the first event
|
||||
func GetLatestOCAState(ctx context.Context, timeout time.Duration) (*cline.OcaAuthState, error) {
|
||||
l, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeout > 0 {
|
||||
if err := l.WaitForFirstEvent(timeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return l.LatestState(), nil
|
||||
}
|
||||
|
||||
// ensureOcaAuthenticated initiates OCA login (if needed) and waits for success using the singleton listener
|
||||
func ensureOcaAuthenticated(ctx context.Context) error {
|
||||
// Ensure listener exists
|
||||
listener, err := GetOcaAuthListener(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OCA auth listener: %w", err)
|
||||
}
|
||||
|
||||
// Briefly wait for first event to know current state
|
||||
_ = listener.WaitForFirstEvent(1 * time.Second)
|
||||
|
||||
// If already authenticated, nothing to do
|
||||
if listener.IsAuthenticated() {
|
||||
fmt.Println("✓ OCA authentication already active.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create gRPC client for initiating login
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
// Start login and wait for authentication
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Initiate login (opens the browser with a callback URL from Cline Core)
|
||||
response, err := client.Ocaaccount.OcaAccountLoginClicked(waitCtx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initiate OCA login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\nOpening browser for OCA authentication...")
|
||||
if response != nil && response.Value != "" {
|
||||
fmt.Printf("If the browser doesn't open automatically, visit this URL:\n%s\n\n", response.Value)
|
||||
}
|
||||
fmt.Println("Waiting for you to complete OCA authentication in your browser...")
|
||||
fmt.Println("(This may take a few moments. Timeout: 5 minutes)")
|
||||
|
||||
// Block until authenticated or timeout
|
||||
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✓ OCA authentication successful!")
|
||||
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,211 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// BannerInfo contains information to display in the session banner
|
||||
type BannerInfo struct {
|
||||
Version string
|
||||
Provider string
|
||||
ModelID string
|
||||
Workdir string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// RenderSessionBanner renders a nice banner showing version, model, and workspace info
|
||||
func RenderSessionBanner(info BannerInfo) string {
|
||||
// Bright white for title
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")). // Bright white
|
||||
Bold(true)
|
||||
|
||||
// Dim gray for regular text (same as huh placeholder)
|
||||
dimStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"})
|
||||
|
||||
// Border color matches mode
|
||||
borderColor := lipgloss.Color("3") // Yellow for plan
|
||||
if info.Mode == "act" {
|
||||
borderColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(borderColor).
|
||||
Padding(1, 4)
|
||||
|
||||
var lines []string
|
||||
|
||||
// Format version with "v" prefix if it starts with a number
|
||||
versionStr := info.Version
|
||||
if len(versionStr) > 0 && versionStr[0] >= '0' && versionStr[0] <= '9' {
|
||||
versionStr = "v" + versionStr
|
||||
}
|
||||
|
||||
// First line: "cline cli vX.X.X" on left, "plan mode" on right
|
||||
leftSide := titleStyle.Render("cline cli preview") + " " + dimStyle.Render(versionStr)
|
||||
|
||||
if info.Mode != "" {
|
||||
modeColor := lipgloss.Color("3") // Yellow for plan
|
||||
if info.Mode == "act" {
|
||||
modeColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
modeStyle := lipgloss.NewStyle().Foreground(modeColor).Bold(true)
|
||||
rightSide := modeStyle.Render(info.Mode + " mode")
|
||||
|
||||
// Calculate spacing to push mode to the right
|
||||
// Assume a reasonable width (we'll adjust based on content)
|
||||
lineWidth := 50
|
||||
leftWidth := lipgloss.Width(leftSide)
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
spacing := lineWidth - leftWidth - rightWidth
|
||||
|
||||
if spacing > 0 {
|
||||
titleLine := leftSide + strings.Repeat(" ", spacing) + rightSide
|
||||
lines = append(lines, titleLine)
|
||||
} else {
|
||||
// If too narrow, just put them on same line with a space
|
||||
lines = append(lines, leftSide+" "+rightSide)
|
||||
}
|
||||
} else {
|
||||
// No mode, just show title
|
||||
lines = append(lines, leftSide)
|
||||
}
|
||||
|
||||
// Model line - dim gray
|
||||
if info.Provider != "" && info.ModelID != "" {
|
||||
lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30)))
|
||||
}
|
||||
|
||||
// Workspace line - dim gray
|
||||
if info.Workdir != "" {
|
||||
lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45)))
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return boxStyle.Render(content)
|
||||
}
|
||||
|
||||
// shortenPath shortens a filesystem path to fit within maxLen
|
||||
func shortenPath(path string, maxLen int) string {
|
||||
// Try to replace home directory with ~ (cross-platform)
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
shortened := "~" + path[len(homeDir):]
|
||||
// Always use ~ version if we can
|
||||
path = shortened
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
|
||||
// If still too long, show last few path components
|
||||
if len(path) > maxLen {
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
if len(parts) > 2 {
|
||||
// Show last 2-3 components
|
||||
lastParts := parts[len(parts)-2:]
|
||||
shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
|
||||
if len(shortened) <= maxLen {
|
||||
return shortened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: truncate with ellipsis
|
||||
if len(path) > maxLen {
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// ExtractBannerInfoFromState extracts banner info from state JSON
|
||||
func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) {
|
||||
var state map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
|
||||
return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
info := BannerInfo{
|
||||
Version: version,
|
||||
}
|
||||
|
||||
// Extract mode
|
||||
if mode, ok := state["mode"].(string); ok {
|
||||
info.Mode = mode
|
||||
}
|
||||
|
||||
// Extract workspace roots
|
||||
if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 {
|
||||
if root, ok := workspaceRoots[0].(map[string]interface{}); ok {
|
||||
if path, ok := root["path"].(string); ok {
|
||||
info.Workdir = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract API configuration to get provider/model
|
||||
if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok {
|
||||
// Try common keys for provider and model (both camelCase and lowercase variants)
|
||||
providerKeys := []string{"apiProvider", "api_provider"}
|
||||
modelKeys := []string{"apiModelId", "api_model_id"}
|
||||
|
||||
// Try to extract provider
|
||||
for _, key := range providerKeys {
|
||||
if provider, ok := apiConfig[key].(string); ok && provider != "" {
|
||||
info.Provider = provider
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract model ID
|
||||
for _, key := range modelKeys {
|
||||
if modelID, ok := apiConfig[key].(string); ok && modelID != "" {
|
||||
info.ModelID = shortenModelID(modelID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// shortenModelID shortens long model IDs for display
|
||||
func shortenModelID(modelID string) string {
|
||||
// Remove date suffixes only if they're at the end (e.g., -20241022)
|
||||
// Check if the model ID ends with -YYYYMMDD pattern
|
||||
if len(modelID) > 9 {
|
||||
suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
|
||||
if suffix[0] == '-' &&
|
||||
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
|
||||
// Verify all remaining chars are digits
|
||||
allDigits := true
|
||||
for _, c := range suffix[1:] {
|
||||
if c < '0' || c > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
return modelID[:len(modelID)-9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still too long, show first 40 chars
|
||||
if len(modelID) > 40 {
|
||||
return modelID[:37] + "..."
|
||||
}
|
||||
|
||||
return modelID
|
||||
}
|
||||
@@ -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 = true
|
||||
|
||||
|
||||
// seems like a reliable way to check for terminals
|
||||
// for now i'm keeping everything as auto
|
||||
// eventually we can define a custom glamour style for ghostty / iterm
|
||||
// https://github.com/charmbracelet/glamour/blob/master/styles/README.md)
|
||||
func detectTerminalTheme() string {
|
||||
switch os.Getenv("TERM_PROGRAM") {
|
||||
case "iTerm.app", "Ghostty":
|
||||
return "dark"
|
||||
}
|
||||
if os.Getenv("GHOSTTY_VERSION") != "" {
|
||||
return "dark"
|
||||
}
|
||||
return "dark"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+168
-40
@@ -3,44 +3,69 @@ package display
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
typewriter *TypewriterPrinter
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
|
||||
// Lipgloss styles that respect outputFormat
|
||||
dimStyle lipgloss.Style
|
||||
greenStyle lipgloss.Style
|
||||
redStyle lipgloss.Style
|
||||
yellowStyle lipgloss.Style
|
||||
blueStyle lipgloss.Style
|
||||
whiteStyle lipgloss.Style
|
||||
boldStyle lipgloss.Style
|
||||
successStyle lipgloss.Style
|
||||
}
|
||||
|
||||
func NewRenderer() *Renderer {
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
func NewRenderer(outputFormat string) *Renderer {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
r := &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
|
||||
// Initialize lipgloss styles (will respect the global color profile)
|
||||
r.dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
r.greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
||||
r.redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
||||
r.yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
|
||||
r.blueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
||||
r.whiteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7"))
|
||||
r.boldStyle = lipgloss.NewStyle().Bold(true)
|
||||
r.successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// RenderMessage renders a message with timestamp and prefix
|
||||
func (r *Renderer) RenderMessage(timestamp, prefix, text string) error {
|
||||
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
clean := r.sanitizeText(text)
|
||||
if clean == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.typewriter.PrintMessageLine(timestamp, prefix, cleanText)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCommand renders a command execution
|
||||
func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error {
|
||||
if isExecuting {
|
||||
r.typewriter.PrintMessageLine(timestamp, "EXEC", command)
|
||||
if newline {
|
||||
output.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "CMD", command)
|
||||
output.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -57,34 +82,58 @@ func formatNumber(n int) string {
|
||||
|
||||
// formatUsageInfo formats token usage information (extracted from RenderAPI)
|
||||
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
|
||||
tokenDetails := fmt.Sprintf("[tokens in: %s, out: %s; cache read: %s, write: %s]",
|
||||
formatNumber(tokensIn),
|
||||
formatNumber(tokensOut),
|
||||
formatNumber(cacheReads),
|
||||
formatNumber(cacheWrites))
|
||||
parts := make([]string, 0, 4)
|
||||
|
||||
return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost)
|
||||
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)
|
||||
}
|
||||
|
||||
// RenderAPI renders API request information
|
||||
func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error {
|
||||
|
||||
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost))
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
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)
|
||||
output.Print(rendered)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", status)
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRetry renders retry information
|
||||
func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error {
|
||||
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(timestamp, "API INFO", message)
|
||||
r.typewriter.PrintMessageLine("API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderTaskCancelled() error {
|
||||
markdown := "## Task cancelled"
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,16 +150,16 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
|
||||
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)
|
||||
for i, taskItem := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", taskItem.Id)
|
||||
|
||||
description := task.Task
|
||||
description := taskItem.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)
|
||||
usageInfo := r.formatUsageInfo(int(taskItem.TokensIn), int(taskItem.TokensOut), int(taskItem.CacheReads), int(taskItem.CacheWrites), taskItem.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
@@ -124,19 +173,18 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message)
|
||||
r.typewriter.PrintMessageLine("[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
output.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
output.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
@@ -174,3 +222,83 @@ func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
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 or non-TTY contexts
|
||||
func (r *Renderer) RenderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering if:
|
||||
// 1. Output format is explicitly "plain"
|
||||
// 2. Not in a TTY (piped output, file redirect, CI, etc.)
|
||||
if r.outputFormat == "plain" || !isTTY() {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if r.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := r.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// Lipgloss-based color rendering methods
|
||||
// These automatically respect the output format via lipgloss color profile
|
||||
|
||||
// Dim renders text in dim gray (bright black)
|
||||
func (r *Renderer) Dim(text string) string {
|
||||
return r.dimStyle.Render(text)
|
||||
}
|
||||
|
||||
// Green renders text in green
|
||||
func (r *Renderer) Green(text string) string {
|
||||
return r.greenStyle.Render(text)
|
||||
}
|
||||
|
||||
// Red renders text in red
|
||||
func (r *Renderer) Red(text string) string {
|
||||
return r.redStyle.Render(text)
|
||||
}
|
||||
|
||||
// Yellow renders text in yellow
|
||||
func (r *Renderer) Yellow(text string) string {
|
||||
return r.yellowStyle.Render(text)
|
||||
}
|
||||
|
||||
// Blue renders text in 256-color blue (index 39)
|
||||
func (r *Renderer) Blue(text string) string {
|
||||
return r.blueStyle.Render(text)
|
||||
}
|
||||
|
||||
// White renders text in white
|
||||
func (r *Renderer) White(text string) string {
|
||||
return r.whiteStyle.Render(text)
|
||||
}
|
||||
|
||||
// Bold renders text in bold
|
||||
func (r *Renderer) Bold(text string) string {
|
||||
return r.boldStyle.Render(text)
|
||||
}
|
||||
|
||||
// Success renders text in green with bold
|
||||
func (r *Renderer) Success(text string) string {
|
||||
return r.successStyle.Render(text)
|
||||
}
|
||||
|
||||
// SuccessWithCheckmark renders text in green with bold and a checkmark prefix
|
||||
func (r *Renderer) SuccessWithCheckmark(text string) string {
|
||||
return r.Success("✓ " + text)
|
||||
}
|
||||
|
||||
// ErrorWithX renders text in red with an X prefix
|
||||
func (r *Renderer) ErrorWithX(text string) string {
|
||||
return r.Red("✗ " + text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"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 and TTY)
|
||||
if shouldMarkdown && outputFormat != "plain" && isTTY() {
|
||||
header := ss.generateRichHeader()
|
||||
rendered, _ := mdRenderer.Render(header)
|
||||
output.Println("")
|
||||
output.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 only in rich mode and TTY
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
|
||||
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" && isTTY() {
|
||||
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") {
|
||||
output.Print(bodyContent)
|
||||
output.Println("")
|
||||
} else {
|
||||
output.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)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,18 +10,26 @@ import (
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
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(),
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,418 +38,96 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
messageKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current streaming state
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
// 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"
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case types.MessageTypeAsk:
|
||||
return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg)
|
||||
case types.MessageTypeSay:
|
||||
return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg)
|
||||
// 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 s.renderer.RenderMessage(timestamp, "🤖", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingAsk handles streaming ASK messages
|
||||
func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same ASK message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// This is an update to the same ASK message - stream the changes
|
||||
if cleanText != streamingMsg.LastText {
|
||||
s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new ASK message
|
||||
s.finishCurrentStream()
|
||||
s.streamAskMessage(cleanText, timestamp, true)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingSay handles streaming SAY messages
|
||||
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeText), string(types.SayTypeCompletionResult):
|
||||
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommand):
|
||||
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeTool):
|
||||
return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
// For non-streaming message types, use regular display
|
||||
return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingText handles streaming text messages
|
||||
func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil // Duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
// Show only the new characters with typewriter effect
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Text changed in a non-incremental way - replace the line
|
||||
s.renderer.ClearLine()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new message
|
||||
s.finishCurrentStream()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
|
||||
// Add typewriter animation for new messages
|
||||
s.typewriterPrint(cleanText)
|
||||
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommand handles command execution messages
|
||||
func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show command being executed with typewriter effect
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommandOutput handles streaming command output
|
||||
func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes with typewriter effect
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - replace the line
|
||||
s.renderer.ClearLine()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New command output message
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show a more concise shell integration warning
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp)
|
||||
s.typewriterPrint("Command executed (output not streamed due to shell integration)")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingTool handles streaming tool messages with deduplication
|
||||
func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formattedTool := s.formatToolMessage(cleanText)
|
||||
|
||||
// Check if this is the exact same tool message we just displayed
|
||||
if streamingMsg.LastToolMessage == formattedTool {
|
||||
return nil // Exact duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is a very similar tool message
|
||||
if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) {
|
||||
return nil // Similar duplicate - ignore it
|
||||
}
|
||||
|
||||
// This is a genuinely new/different tool message
|
||||
s.finishCurrentStream()
|
||||
fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool)
|
||||
|
||||
// Store the formatted tool message for deduplication
|
||||
s.state.StreamingMessage.LastToolMessage = formattedTool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamAskMessage streams an ASK message in a natural format
|
||||
func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) {
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := s.parseJSON(text, &askData); err != nil {
|
||||
// Display as text but sanitized
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response)
|
||||
|
||||
// Display options if available
|
||||
if len(askData.Options) > 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
for i, option := range askData.Options {
|
||||
fmt.Printf("\n%d. %s", i+1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamAskMessageUpdate handles updates to an existing ASK message
|
||||
func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) {
|
||||
var oldAskData, newAskData types.AskData
|
||||
|
||||
oldErr := s.parseJSON(oldText, &oldAskData)
|
||||
newErr := s.parseJSON(newText, &newAskData)
|
||||
|
||||
if oldErr != nil || newErr != nil {
|
||||
// Handle plain text incremental updates
|
||||
if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) {
|
||||
newChars := newText[len(oldText):]
|
||||
fmt.Print(newChars)
|
||||
} else {
|
||||
// Non-incremental change - clear line and reprint everything
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured updates
|
||||
if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) {
|
||||
newChars := newAskData.Response[len(oldAskData.Response):]
|
||||
fmt.Print(newChars)
|
||||
} else if oldAskData.Response != newAskData.Response {
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
if len(newAskData.Options) > len(oldAskData.Options) {
|
||||
if len(oldAskData.Options) == 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
}
|
||||
|
||||
for i := len(oldAskData.Options); i < len(newAskData.Options); i++ {
|
||||
fmt.Printf("\n%d. %s", i+1, newAskData.Options[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (s *StreamingDisplay) typewriterPrint(text string) {
|
||||
// Use the renderer's typewriter for consistent animation
|
||||
s.renderer.typewriter.Print(text)
|
||||
}
|
||||
|
||||
// finishCurrentStream completes any ongoing streaming message
|
||||
func (s *StreamingDisplay) finishCurrentStream() {
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
if streamingMsg.CurrentKey != "" {
|
||||
//fmt.Println() // Add newline to finish the current streaming message
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
}
|
||||
|
||||
// getMessagePrefix returns the appropriate prefix for a message type
|
||||
func (s *StreamingDisplay) getMessagePrefix(say string) string {
|
||||
switch say {
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "✅ RESULT"
|
||||
case string(types.SayTypeText):
|
||||
return "🤖"
|
||||
default:
|
||||
return "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolMessage formats tool call messages for better readability
|
||||
func (s *StreamingDisplay) formatToolMessage(text string) string {
|
||||
var toolCall map[string]interface{}
|
||||
if err := s.parseJSON(text, &toolCall); err == nil {
|
||||
if tool, ok := toolCall["tool"].(string); ok {
|
||||
parts := []string{tool}
|
||||
|
||||
if path, ok := toolCall["path"].(string); ok && path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path=%s", path))
|
||||
}
|
||||
|
||||
if content, ok := toolCall["content"].(string); ok && content != "" {
|
||||
if len(content) > 50 {
|
||||
parts = append(parts, fmt.Sprintf("content=%s...", content[:50]))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("content=%s", content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON or doesn't have expected structure, return truncated
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates
|
||||
func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool {
|
||||
parts1 := strings.Fields(msg1)
|
||||
parts2 := strings.Fields(msg2)
|
||||
|
||||
if len(parts1) == 0 || len(parts2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the first word (tool name) is the same, check for similarity
|
||||
if parts1[0] == parts2[0] {
|
||||
// For file operations, check if the path is the same
|
||||
if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") {
|
||||
path1 := s.extractPathFromToolMessage(msg1)
|
||||
path2 := s.extractPathFromToolMessage(msg2)
|
||||
|
||||
if path1 != "" && path1 == path2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very similar content (>80% similarity), consider them duplicates
|
||||
similarity := s.calculateStringSimilarity(msg1, msg2)
|
||||
return similarity > 0.8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPathFromToolMessage extracts the path parameter from a tool message
|
||||
func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string {
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "path=") {
|
||||
return strings.TrimPrefix(part, "path=")
|
||||
}
|
||||
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)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates a simple similarity ratio between two strings
|
||||
func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
func (s *StreamingDisplay) FreezeActiveSegment() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.activeSegment != nil {
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
shorter, longer := s1, s2
|
||||
if len(s1) > len(s2) {
|
||||
shorter, longer = s2, s1
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for i, r := range shorter {
|
||||
if i < len(longer) && rune(longer[i]) == r {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(longer))
|
||||
}
|
||||
|
||||
// parseJSON is a helper function to parse JSON with error handling
|
||||
func (s *StreamingDisplay) parseJSON(text string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(text), v)
|
||||
}
|
||||
|
||||
// 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,446 @@
|
||||
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 and in a TTY
|
||||
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering if plain mode or not in TTY
|
||||
if tr.outputFormat == "plain" || !isTTY() {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,8 @@ func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
// PrintMessageLine prints a complete message line with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) {
|
||||
// Print the timestamp and prefix with 10-char padding
|
||||
tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix)
|
||||
// Print the message text with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
|
||||
tp.PrintfInstant("%s: ", prefix)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
@@ -193,9 +190,8 @@ func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance
|
||||
func TypewriterPrintMessageLine(timestamp, prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(timestamp, prefix, text)
|
||||
func TypewriterPrintMessageLine(prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/terminal"
|
||||
"github.com/cline/cli/pkg/cli/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewDoctorCommand creates the doctor command
|
||||
func NewDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Check system health and diagnose problems",
|
||||
Long: `Check the health of your Cline CLI installation and diagnose problems.
|
||||
|
||||
Currently this command performs the following checks and fixes:
|
||||
|
||||
Terminal Configuration:
|
||||
- Detects your terminal emulator (VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty)
|
||||
- Configures shift+enter to insert newlines in multiline input
|
||||
- Creates backups before modifying configuration files
|
||||
- Supported terminals: VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty
|
||||
- iTerm2 works by default, Terminal.app requires manual setup
|
||||
|
||||
CLI Updates:
|
||||
- Checks npm registry for the latest version
|
||||
- Automatically installs updates via npm if available
|
||||
- Respects NO_AUTO_UPDATE environment variable
|
||||
- Skipped in CI environments
|
||||
|
||||
Note: Future versions will include additional health checks for Node.js version,
|
||||
npm availability, Cline Core connectivity, database integrity, and more.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDoctorChecks()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runDoctorChecks performs all doctor diagnostics and configuration
|
||||
func runDoctorChecks() error {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Bold("Cline Doctor - System Health Check"))
|
||||
|
||||
// Configure terminal keybindings (terminal.go prints its own status)
|
||||
fmt.Printf("%s\n\n", renderer.Dim("━━━ Terminal Configuration ━━━"))
|
||||
terminal.SetupKeyboardSync()
|
||||
|
||||
// Check for updates (updater.go prints its own status)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("━━━ CLI Updates ━━━"))
|
||||
updater.CheckAndUpdateSync(global.Config.Verbose, true)
|
||||
|
||||
// Summary
|
||||
fmt.Printf("\n%s\n", renderer.Dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
|
||||
fmt.Printf("\n%s\n\n", renderer.SuccessWithCheckmark("Health check complete"))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
@@ -39,7 +43,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
|
||||
return nil, fmt.Errorf("failed to find available ports: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
@@ -58,7 +64,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
if Config.Verbose {
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
}
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
@@ -92,11 +100,22 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
|
||||
return nil, fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -111,7 +130,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
|
||||
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
@@ -130,7 +151,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
|
||||
}
|
||||
|
||||
fullAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
if Config.Verbose {
|
||||
fmt.Println("Waiting for services to start and self-register in SQLite...")
|
||||
}
|
||||
|
||||
// Use RetryOperation to wait for instance to be ready
|
||||
var instance *common.CoreInstanceInfo
|
||||
@@ -164,11 +187,22 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
|
||||
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -209,66 +243,259 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
}
|
||||
|
||||
// Get the directory where the cline binary is located
|
||||
execPath, err := os.Executable()
|
||||
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("./cli/bin/cline-host",
|
||||
cmd := exec.Command(clineHostPath,
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
|
||||
// Create port-tagged log file in OS temp directory with full address
|
||||
logFileName := fmt.Sprintf("cline-core-debug-localhost-%d.log", corePort)
|
||||
logFilePath := fmt.Sprintf("%s/%s", os.TempDir(), logFileName)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag instead of CLINE_DIR env var
|
||||
args := []string{"cline-core.js",
|
||||
// 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 the executable path and resolve symlinks (for npm global installs)
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks to get the real path
|
||||
// For npm global installs, execPath might be a symlink like:
|
||||
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
|
||||
realPath, err := filepath.EvalSymlinks(execPath)
|
||||
if err != nil {
|
||||
// If we can't resolve symlinks, fall back to the original path
|
||||
realPath = execPath
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
binDir := path.Dir(realPath)
|
||||
installDir := path.Dir(binDir)
|
||||
clineCorePath := path.Join(installDir, "cline-core.js")
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Executable path: %s\n", execPath)
|
||||
if realPath != execPath {
|
||||
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
|
||||
}
|
||||
fmt.Printf("Bin directory: %s\n", binDir)
|
||||
fmt.Printf("Install directory: %s\n", installDir)
|
||||
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
|
||||
}
|
||||
|
||||
// Check if cline-core.js exists at the primary location
|
||||
var finalClineCorePath string
|
||||
var finalInstallDir string
|
||||
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
|
||||
// Development mode: Try ../../dist-standalone/cline-core.js
|
||||
// This handles the case where we're running from cli/bin/cline
|
||||
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
|
||||
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
|
||||
}
|
||||
|
||||
finalClineCorePath = devClineCorePath
|
||||
finalInstallDir = devInstallDir
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
|
||||
}
|
||||
} else {
|
||||
finalClineCorePath = clineCorePath
|
||||
finalInstallDir = installDir
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 using system node
|
||||
args := []string{finalClineCorePath,
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args)
|
||||
fmt.Printf("DEBUG: Working directory: ./dist-standalone\n")
|
||||
fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using system node\n")
|
||||
}
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to dist-standalone (relative to project root)
|
||||
cmd.Dir = "./dist-standalone"
|
||||
// Set working directory to installation root
|
||||
cmd.Dir = finalInstallDir
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set environment variables (removed CLINE_DIR)
|
||||
// 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 both real and fake node_modules
|
||||
// The fake node_modules contains the vscode stub that can't be in the real node_modules
|
||||
env := os.Environ()
|
||||
realNodeModules := path.Join(finalInstallDir, "node_modules")
|
||||
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
|
||||
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
|
||||
|
||||
env = append(env,
|
||||
fmt.Sprintf("NODE_PATH=%s", nodePath),
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-core: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
|
||||
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type Port uint16
|
||||
@@ -22,6 +24,15 @@ type GlobalConfig struct {
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
|
||||
// Version info - set at build time via ldflags
|
||||
// Version is the Cline Core version (from root package.json)
|
||||
Version = "dev"
|
||||
// CliVersion is the CLI package version (from cli/package.json)
|
||||
CliVersion = "dev"
|
||||
Commit = "unknown"
|
||||
Date = "unknown"
|
||||
BuiltBy = "unknown"
|
||||
)
|
||||
|
||||
func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
@@ -38,6 +49,12 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure lipgloss color profile based on output format
|
||||
if cfg.OutputFormat == "plain" {
|
||||
lipgloss.SetColorProfile(termenv.Ascii) // NO COLOR mode
|
||||
}
|
||||
// Otherwise lipgloss auto-detects terminal capabilities (default behavior)
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
|
||||
@@ -65,3 +82,32 @@ func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
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
|
||||
}
|
||||
@@ -110,6 +110,43 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -223,15 +260,15 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.ensureDefaultInstance(instances); err != nil {
|
||||
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 {
|
||||
// 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
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
@@ -25,262 +27,263 @@ func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
// Handle processes ASK messages
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
// 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, timestamp)
|
||||
return h.handleFollowup(msg, dc)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc, timestamp)
|
||||
return h.handlePlanModeRespond(msg, dc)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc, timestamp)
|
||||
return h.handleAPIReqFailed(msg, dc)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc, timestamp)
|
||||
return h.handleResumeTask(msg, dc)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc, timestamp)
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc, timestamp)
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc, timestamp)
|
||||
return h.handleAutoApprovalMaxReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc, timestamp)
|
||||
return h.handleNewTask(msg, dc)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc, timestamp)
|
||||
return h.handleCondense(msg, dc)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc, timestamp)
|
||||
return h.handleReportBug(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var question string
|
||||
var options []string
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
|
||||
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = msg.Text
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the body content
|
||||
output.Print(body)
|
||||
} else {
|
||||
// Non-streaming mode: render header + body together
|
||||
header := dc.ToolRenderer.GenerateAskFollowupHeader()
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
output.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
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(msg.Text), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = msg.Text
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
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 != "" {
|
||||
output.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)
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
|
||||
// Render body
|
||||
output.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// showApprovalHint displays a hint in non-interactive mode about how to approve/deny
|
||||
func (h *AskHandler) showApprovalHint(dc *DisplayContext) {
|
||||
if !dc.IsInteractive {
|
||||
output.Printf("\n%s\n", dc.Renderer.Dim("Cline is requesting approval to use this tool"))
|
||||
output.Printf("%s\n", dc.Renderer.Dim("Use cline task send --approve or --deny to respond"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := msg.Text
|
||||
// Check if this command was flagged despite auto-approval settings
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings turned on for safe commands
|
||||
hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP")
|
||||
if hasAutoApprovalConflict {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command))
|
||||
|
||||
if hasAutoApprovalConflict {
|
||||
fmt.Printf("\nThe model has determined this command requires explicit approval.\n")
|
||||
} else {
|
||||
fmt.Printf("\nApproval required for this command.\n")
|
||||
}
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
output.Print(rendered)
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommandOutput: %w", err)
|
||||
}
|
||||
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
|
||||
fmt.Printf("\nApprove to proceed while this command runs in the background.\n")
|
||||
fmt.Printf("%s", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
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, timestamp string) error {
|
||||
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(timestamp, "TOOL", msg.Text)
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
// renderToolMessage renders a tool message with appropriate formatting
|
||||
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path))
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the content preview
|
||||
contentPreview := dc.ToolRenderer.GenerateToolContentPreview(&tool)
|
||||
if contentPreview != "" {
|
||||
output.Print("\n")
|
||||
output.Print(contentPreview)
|
||||
}
|
||||
|
||||
fmt.Printf("Preview: %s\n", preview)
|
||||
} else {
|
||||
// Non-streaming mode: render full approval (header + preview)
|
||||
rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApproval required.\n")
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text))
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.")
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.")
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text))
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text))
|
||||
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, timestamp string) error {
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url))
|
||||
err := dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
|
||||
h.showApprovalHint(dc)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
@@ -292,7 +295,7 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text)
|
||||
return dc.Renderer.RenderMessage("MCP", msg.Text, true)
|
||||
}
|
||||
|
||||
var operation string
|
||||
@@ -305,22 +308,25 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName))
|
||||
err := dc.Renderer.RenderMessage("MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return err
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text))
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text))
|
||||
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, timestamp string) error {
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
@@ -330,10 +336,10 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text))
|
||||
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(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:")
|
||||
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)
|
||||
}
|
||||
@@ -349,6 +355,6 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text)
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,16 @@ type MessageHandler interface {
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
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
|
||||
@@ -94,15 +98,13 @@ func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) er
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
@@ -31,269 +33,346 @@ func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc, timestamp)
|
||||
return h.handleTask(msg, dc)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc, timestamp)
|
||||
return h.handleError(msg, dc)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc, timestamp)
|
||||
return h.handleAPIReqStarted(msg, dc)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc, timestamp)
|
||||
return h.handleAPIReqFinished(msg, dc)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc, timestamp)
|
||||
return h.handleText(msg, dc)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc, timestamp)
|
||||
return h.handleReasoning(msg, dc)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc, timestamp)
|
||||
return h.handleUserFeedback(msg, dc)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc, timestamp)
|
||||
return h.handleUserFeedbackDiff(msg, dc)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc, timestamp)
|
||||
return h.handleAPIReqRetried(msg, dc)
|
||||
case string(types.SayTypeErrorRetry):
|
||||
return h.handleErrorRetry(msg, dc)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc, timestamp)
|
||||
return h.handleShellIntegrationWarning(msg, dc)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc, timestamp)
|
||||
return h.handleBrowserAction(msg, dc)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc, timestamp)
|
||||
return h.handleBrowserActionResult(msg, dc)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc, timestamp)
|
||||
return h.handleMcpServerRequestStarted(msg, dc)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc, timestamp)
|
||||
return h.handleMcpServerResponse(msg, dc)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc, timestamp)
|
||||
return h.handleMcpNotification(msg, dc)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc, timestamp)
|
||||
return h.handleDiffError(msg, dc)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc, timestamp)
|
||||
return h.handleDeletedAPIReqs(msg, dc)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc, timestamp)
|
||||
return h.handleClineignoreError(msg, dc)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc, timestamp)
|
||||
return h.handleLoadMcpDocumentation(msg, dc)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc, timestamp)
|
||||
return h.handleInfo(msg, dc)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc, timestamp)
|
||||
return h.handleTaskProgress(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text)
|
||||
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, timestamp string) error {
|
||||
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(timestamp, "API INFO", msg.Text)
|
||||
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(timestamp, "API INFO", "Request Cancelled")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
|
||||
}
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo)
|
||||
return dc.Renderer.RenderAPI("request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(timestamp,
|
||||
return dc.Renderer.RenderRetry(
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo)
|
||||
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, timestamp string) error {
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
prefix := "ASST TEXT"
|
||||
if dc.MessageIndex == 0 {
|
||||
prefix = "USER"
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("%s", rendered)
|
||||
output.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
// 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)
|
||||
output.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)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "THINKING", msg.Text)
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
output.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)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
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")
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "RESULT", text)
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
output.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)
|
||||
output.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, timestamp string) error {
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]")
|
||||
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, timestamp string) error {
|
||||
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(timestamp, "USER DIFF", msg.Text)
|
||||
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(timestamp, "USER DIFF", message)
|
||||
return dc.Renderer.RenderMessage("USER DIFF", message, true)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request")
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(msg.Text)
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", command)
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
commandOutput := msg.Text
|
||||
return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path))
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation")
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
fmt.Printf("Content: %s\n", preview)
|
||||
// Use unified ToolRenderer
|
||||
rendered := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
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
|
||||
rendered := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.")
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url))
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -306,27 +385,27 @@ func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayCon
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text)
|
||||
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(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
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(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action))
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -340,79 +419,103 @@ func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *Disp
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server")
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text))
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text))
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved")
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.")
|
||||
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, timestamp string) error {
|
||||
// This message includes api metrics of deleted messages, which we do not log
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint restored")
|
||||
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, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text))
|
||||
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)
|
||||
}
|
||||
|
||||
// handleCheckpointCreated handles checkpoint created messages
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint created")
|
||||
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)
|
||||
output.Print(rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation")
|
||||
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, timestamp string) error {
|
||||
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, timestamp string) error {
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text))
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text)
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
|
||||
}
|
||||
|
||||
+228
-110
@@ -4,16 +4,60 @@ 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",
|
||||
@@ -23,7 +67,7 @@ func NewInstanceCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceDefaultCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
@@ -31,7 +75,7 @@ func NewInstanceCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAll bool
|
||||
var killAllCLI bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
@@ -39,11 +83,11 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
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 killAll && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all flag and address argument")
|
||||
if killAllCLI && len(args) > 0 {
|
||||
return fmt.Errorf("cannot specify both --all-cli flag and address argument")
|
||||
}
|
||||
if !killAll && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all is not specified")
|
||||
if !killAllCLI && len(args) != 1 {
|
||||
return fmt.Errorf("requires exactly one address argument when --all-cli is not specified")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,76 +99,20 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if killAll {
|
||||
return killAllInstances(ctx, registry)
|
||||
if killAllCLI {
|
||||
return killAllCLIInstances(ctx, registry)
|
||||
} else {
|
||||
return killSingleInstance(ctx, registry, args[0])
|
||||
return global.KillInstanceByAddress(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&killAll, "all", false, "kill all running instances")
|
||||
cmd.Flags().BoolVarP(&killAllCLI, "all-cli", "a", false, "kill all running CLI instances (excludes JetBrains)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killSingleInstance(ctx context.Context, registry *global.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)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
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) {
|
||||
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 {
|
||||
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 killAllInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
@@ -136,12 +124,42 @@ func killAllInstances(ctx context.Context, registry *global.ClientRegistry) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Killing %d instances...\n", len(instances))
|
||||
// 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
|
||||
killedAddresses := make(map[string]bool)
|
||||
|
||||
// Kill all instances
|
||||
for _, instance := range instances {
|
||||
// Kill all CLI instances
|
||||
for _, instance := range cliInstances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
@@ -151,31 +169,42 @@ func killAllInstances(ctx context.Context, registry *global.ClientRegistry) erro
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
|
||||
killedAddresses[instance.Address] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all instances to clean up their registry entries
|
||||
fmt.Printf("Waiting for instances to clean up registry entries...\n")
|
||||
// Wait for killed instances to clean up their registry entries
|
||||
if len(killedAddresses) > 0 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
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)
|
||||
// Check if any of the killed instances are still in the registry
|
||||
stillPresent := []string{}
|
||||
for _, remaining := range remainingInstances {
|
||||
fmt.Printf(" - %s\n", remaining.Address)
|
||||
if killedAddresses[remaining.Address] {
|
||||
stillPresent = append(stillPresent, remaining.Address)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stillPresent) == 0 {
|
||||
fmt.Printf("✓ All killed instances successfully removed from registry.\n")
|
||||
break
|
||||
}
|
||||
|
||||
if i == maxWaitTime-1 {
|
||||
fmt.Printf("⚠ %d killed instance(s) still in registry after %d seconds\n", len(stillPresent), maxWaitTime)
|
||||
for _, addr := range stillPresent {
|
||||
fmt.Printf(" - %s\n", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,14 +296,22 @@ func newInstanceListCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always output a table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
// 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 = "*"
|
||||
isDefault = "✓"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
@@ -282,9 +319,11 @@ func newInstanceListCommand() *cobra.Command {
|
||||
lastSeen = instance.LastSeen.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Get PID via RPC if instance is healthy
|
||||
pid := "N/A"
|
||||
// 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)
|
||||
@@ -294,19 +333,84 @@ func newInstanceListCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get platform from host bridge
|
||||
if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil {
|
||||
platform = detectedPlatform
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
instance.Address,
|
||||
instance.Status,
|
||||
instance.Version,
|
||||
lastSeen,
|
||||
pid,
|
||||
isDefault,
|
||||
)
|
||||
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
|
||||
mdRenderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain table if markdown renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
rendered, err := mdRenderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
// Post-process to colorize status values
|
||||
colorRenderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
rendered = strings.ReplaceAll(rendered, "SERVING", colorRenderer.Green("SERVING"))
|
||||
rendered = strings.ReplaceAll(rendered, "✓", colorRenderer.Green("✓"))
|
||||
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", colorRenderer.Red("NOT_SERVING"))
|
||||
rendered = strings.ReplaceAll(rendered, "UNKNOWN", colorRenderer.Yellow("UNKNOWN"))
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
}
|
||||
fmt.Println("\n")
|
||||
}
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -314,10 +418,10 @@ func newInstanceListCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInstanceUseCommand() *cobra.Command {
|
||||
func newInstanceDefaultCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use <address>",
|
||||
Aliases: []string{"u"},
|
||||
Use: "default <address>",
|
||||
Aliases: []string{"d"},
|
||||
Short: "Set the default Cline instance",
|
||||
Long: `Set the default Cline instance to use for subsequent commands.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
@@ -350,6 +454,8 @@ func newInstanceUseCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
var setDefault bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
@@ -374,15 +480,27 @@ func newInstanceNewCommand() *cobra.Command {
|
||||
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")
|
||||
|
||||
// If --default flag provided, set this instance as the default
|
||||
if setDefault {
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
fmt.Printf("Warning: Failed to set as default: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Status: Set as default instance\n")
|
||||
}
|
||||
} else {
|
||||
// Otherwise, check if EnsureDefaultInstance already set it as default
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance")
|
||||
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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
|
||||
colorRenderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
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
|
||||
if markForDeletion {
|
||||
line = colorRenderer.Red(line)
|
||||
}
|
||||
|
||||
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,167 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// SuspendInputMsg tells the input model to suspend and hide
|
||||
type SuspendInputMsg struct{}
|
||||
|
||||
// ResumeInputMsg tells the input model to resume and show
|
||||
type ResumeInputMsg struct{}
|
||||
|
||||
// OutputCoordinator manages terminal output and coordinates with interactive input
|
||||
type OutputCoordinator struct {
|
||||
mu sync.Mutex
|
||||
program *tea.Program
|
||||
inputVisible atomic.Bool
|
||||
inputModel *InputModel // Reference to current input model for state restoration
|
||||
restartCallback func(*InputModel) // Callback to restart the program with preserved state
|
||||
}
|
||||
|
||||
var (
|
||||
globalCoordinator *OutputCoordinator
|
||||
coordinatorMu sync.Mutex
|
||||
)
|
||||
|
||||
// GetCoordinator returns the global output coordinator instance
|
||||
func GetCoordinator() *OutputCoordinator {
|
||||
coordinatorMu.Lock()
|
||||
defer coordinatorMu.Unlock()
|
||||
|
||||
if globalCoordinator == nil {
|
||||
globalCoordinator = &OutputCoordinator{}
|
||||
}
|
||||
return globalCoordinator
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program for input coordination
|
||||
func (oc *OutputCoordinator) SetProgram(program *tea.Program) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.program = program
|
||||
}
|
||||
|
||||
// SetInputModel sets the current input model reference for state preservation
|
||||
func (oc *OutputCoordinator) SetInputModel(model *InputModel) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.inputModel = model
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the callback for restarting the program
|
||||
func (oc *OutputCoordinator) SetRestartCallback(callback func(*InputModel)) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.restartCallback = callback
|
||||
}
|
||||
|
||||
// SetInputVisible sets whether input is currently visible
|
||||
func (oc *OutputCoordinator) SetInputVisible(visible bool) {
|
||||
oc.inputVisible.Store(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible returns whether input is currently visible
|
||||
func (oc *OutputCoordinator) IsInputVisible() bool {
|
||||
return oc.inputVisible.Load()
|
||||
}
|
||||
|
||||
// Printf prints formatted output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Printf(format string, args ...interface{}) {
|
||||
oc.mu.Lock()
|
||||
prog := oc.program
|
||||
model := oc.inputModel
|
||||
restart := oc.restartCallback
|
||||
visible := oc.inputVisible.Load()
|
||||
oc.mu.Unlock()
|
||||
|
||||
if visible && prog != nil && restart != nil && model != nil {
|
||||
// Kill/restart approach: completely stop the program, print, restart with state
|
||||
|
||||
// 1. Save the current input state (text, cursor position, etc.)
|
||||
savedModel := model.Clone()
|
||||
|
||||
// 2. Manually clear the form from terminal BEFORE quitting
|
||||
clearCodes := model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
|
||||
// 3. Quit the program
|
||||
prog.Send(Quit())
|
||||
|
||||
// Small delay to let program actually quit
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// 4. Print the output
|
||||
fmt.Printf(format, args...)
|
||||
|
||||
// 5. Restart with preserved state
|
||||
restart(savedModel)
|
||||
} else {
|
||||
// No input showing, just print normally
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Println prints a line with newline, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Println(args ...interface{}) {
|
||||
oc.Printf("%s\n", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Print prints output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Print(args ...interface{}) {
|
||||
oc.Printf("%s", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Package-level convenience functions
|
||||
|
||||
// Printf prints formatted output via the global coordinator
|
||||
func Printf(format string, args ...interface{}) {
|
||||
GetCoordinator().Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println prints a line with newline via the global coordinator
|
||||
func Println(args ...interface{}) {
|
||||
GetCoordinator().Println(args...)
|
||||
}
|
||||
|
||||
// Print prints output via the global coordinator
|
||||
func Print(args ...interface{}) {
|
||||
GetCoordinator().Print(args...)
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program on the global coordinator
|
||||
func SetProgram(program *tea.Program) {
|
||||
GetCoordinator().SetProgram(program)
|
||||
}
|
||||
|
||||
// SetInputVisible sets input visibility on the global coordinator
|
||||
func SetInputVisible(visible bool) {
|
||||
GetCoordinator().SetInputVisible(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible checks input visibility on the global coordinator
|
||||
func IsInputVisible() bool {
|
||||
return GetCoordinator().IsInputVisible()
|
||||
}
|
||||
|
||||
// SetInputModel sets the input model on the global coordinator
|
||||
func SetInputModel(model *InputModel) {
|
||||
GetCoordinator().SetInputModel(model)
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the restart callback on the global coordinator
|
||||
func SetRestartCallback(callback func(*InputModel)) {
|
||||
GetCoordinator().SetRestartCallback(callback)
|
||||
}
|
||||
|
||||
// Quit returns a Bubble Tea quit message
|
||||
func Quit() tea.Msg {
|
||||
return tea.Quit()
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// InputType represents the type of input being collected
|
||||
type InputType int
|
||||
|
||||
const INPUT_WIDTH = 46
|
||||
|
||||
const (
|
||||
InputTypeMessage InputType = iota
|
||||
InputTypeApproval
|
||||
InputTypeFeedback
|
||||
)
|
||||
|
||||
// InputSubmitMsg is sent when the user submits input
|
||||
type InputSubmitMsg struct {
|
||||
Value string
|
||||
InputType InputType
|
||||
Approved bool // For approval type
|
||||
NeedsFeedback bool // For approval type
|
||||
NoAskAgain bool // For approval type - indicates "don't ask again" was selected
|
||||
}
|
||||
|
||||
// InputCancelMsg is sent when the user cancels input (Ctrl+C)
|
||||
type InputCancelMsg struct{}
|
||||
|
||||
// ChangeInputTypeMsg changes the current input type
|
||||
type ChangeInputTypeMsg struct {
|
||||
InputType InputType
|
||||
Title string
|
||||
Placeholder string
|
||||
}
|
||||
|
||||
// editorFinishedMsg is sent when the external editor finishes
|
||||
type editorFinishedMsg struct {
|
||||
content []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// InputModel is the bubbletea model for interactive input
|
||||
type InputModel struct {
|
||||
textarea textarea.Model
|
||||
suspended bool
|
||||
savedValue string
|
||||
inputType InputType
|
||||
title string
|
||||
placeholder string
|
||||
currentMode string // "plan" or "act"
|
||||
width int
|
||||
lastHeight int // Track height for cleanup on submit
|
||||
|
||||
// For approval type
|
||||
approvalOptions []string
|
||||
selectedOption int
|
||||
pendingApproval bool // Stores approval decision when transitioning to feedback input
|
||||
|
||||
// Styles (huh-inspired theme)
|
||||
styles fieldStyles
|
||||
}
|
||||
|
||||
// fieldStyles holds the styling for the input field
|
||||
type fieldStyles struct {
|
||||
base lipgloss.Style
|
||||
title lipgloss.Style
|
||||
textArea lipgloss.Style
|
||||
cursor lipgloss.Style
|
||||
placeholder lipgloss.Style
|
||||
selector lipgloss.Style
|
||||
selectedOption lipgloss.Style
|
||||
option lipgloss.Style
|
||||
}
|
||||
|
||||
// newFieldStyles creates huh-inspired styles (Charm theme)
|
||||
func newFieldStyles() fieldStyles {
|
||||
// Charm theme colors
|
||||
indigo := lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
||||
fuchsia := lipgloss.Color("#F780E2")
|
||||
normalFg := lipgloss.AdaptiveColor{Light: "235", Dark: "252"}
|
||||
green := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
|
||||
|
||||
return fieldStyles{
|
||||
base: lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(lipgloss.Color("238")),
|
||||
title: lipgloss.NewStyle().
|
||||
Foreground(indigo).
|
||||
Bold(true),
|
||||
textArea: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
cursor: lipgloss.NewStyle().
|
||||
Foreground(green),
|
||||
placeholder: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"}),
|
||||
selector: lipgloss.NewStyle().
|
||||
Foreground(fuchsia).
|
||||
SetString("> "),
|
||||
selectedOption: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
option: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
}
|
||||
}
|
||||
|
||||
// NewInputModel creates a new input model
|
||||
func NewInputModel(inputType InputType, title, placeholder, currentMode string) InputModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = placeholder
|
||||
ta.Focus()
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!)
|
||||
ta.SetHeight(5)
|
||||
// Don't set width here - let WindowSizeMsg handle it
|
||||
ta.SetWidth(INPUT_WIDTH)
|
||||
|
||||
// Configure keybindings like huh does:
|
||||
// alt+enter and ctrl+j for newlines (textarea will handle these)
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply huh-like styling
|
||||
styles := newFieldStyles()
|
||||
|
||||
// Set cursor color based on mode
|
||||
cursorColor := lipgloss.Color("3") // Yellow for plan
|
||||
if currentMode == "act" {
|
||||
cursorColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling
|
||||
ta.FocusedStyle.Placeholder = styles.placeholder
|
||||
ta.FocusedStyle.Text = styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling
|
||||
ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor)
|
||||
ta.Cursor.TextStyle = styles.textArea
|
||||
|
||||
m := InputModel{
|
||||
textarea: ta,
|
||||
inputType: inputType,
|
||||
title: title,
|
||||
placeholder: placeholder,
|
||||
currentMode: currentMode,
|
||||
width: 0, // Will be set by first WindowSizeMsg
|
||||
styles: styles,
|
||||
}
|
||||
|
||||
// For approval type, set up options
|
||||
if inputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, and don't ask again for this task",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m *InputModel) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m *InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case editorFinishedMsg:
|
||||
// External editor finished
|
||||
if msg.err == nil && len(msg.content) > 0 {
|
||||
m.textarea.SetValue(string(msg.content))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case SuspendInputMsg:
|
||||
// Save current value and suspend
|
||||
m.savedValue = m.textarea.Value()
|
||||
m.suspended = true
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case ResumeInputMsg:
|
||||
// Restore value and resume
|
||||
m.textarea.SetValue(m.savedValue)
|
||||
m.suspended = false
|
||||
return m, nil
|
||||
|
||||
case ChangeInputTypeMsg:
|
||||
// Change input type (e.g., from approval to feedback)
|
||||
m.inputType = msg.InputType
|
||||
m.title = msg.Title
|
||||
m.placeholder = msg.Placeholder
|
||||
m.textarea.Placeholder = msg.Placeholder
|
||||
m.textarea.SetValue("")
|
||||
m.textarea.Focus()
|
||||
|
||||
if msg.InputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, and don't ask again for this task",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
default:
|
||||
// Forward all other messages to textarea (including blink ticks)
|
||||
if !m.suspended && (m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback) {
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.suspended {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle keys for text input types (Message/Feedback)
|
||||
if m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "ctrl+e":
|
||||
// Open external editor (like huh does)
|
||||
return m, m.openEditor()
|
||||
|
||||
case "enter":
|
||||
// Intercept enter for submit (textarea handles alt+enter and ctrl+j for newlines)
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up", "down", "left", "right":
|
||||
// Let textarea handle navigation
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Pass all other keys to textarea (including alt+enter, ctrl+j for newlines)
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Handle keys for approval type
|
||||
if m.inputType == InputTypeApproval {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "enter":
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up":
|
||||
if m.selectedOption > 0 {
|
||||
m.selectedOption--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down":
|
||||
if m.selectedOption < len(m.approvalOptions)-1 {
|
||||
m.selectedOption++
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleSubmit handles submission based on input type
|
||||
func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
switch m.inputType {
|
||||
case InputTypeMessage:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeMessage,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeApproval:
|
||||
selected := m.approvalOptions[m.selectedOption]
|
||||
approved := strings.HasPrefix(selected, "Yes")
|
||||
needsFeedback := strings.Contains(selected, "feedback")
|
||||
noAskAgain := strings.Contains(selected, "don't ask again")
|
||||
|
||||
if needsFeedback {
|
||||
// Store the approval decision before switching to feedback input
|
||||
m.pendingApproval = approved
|
||||
// Switch to feedback input
|
||||
return m, func() tea.Msg {
|
||||
return ChangeInputTypeMsg{
|
||||
InputType: InputTypeFeedback,
|
||||
Title: "Your feedback",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: "",
|
||||
InputType: InputTypeApproval,
|
||||
Approved: approved,
|
||||
NeedsFeedback: false,
|
||||
NoAskAgain: noAskAgain,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeFeedback:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeFeedback,
|
||||
Approved: m.pendingApproval, // Pass the stored approval decision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the model
|
||||
func (m *InputModel) View() string {
|
||||
if m.suspended {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Render title with mode indicator
|
||||
yellow := lipgloss.Color("3")
|
||||
blue := lipgloss.Color("39")
|
||||
|
||||
modeStyle := lipgloss.NewStyle().Bold(true)
|
||||
if m.currentMode == "plan" {
|
||||
modeStyle = modeStyle.Foreground(yellow)
|
||||
} else {
|
||||
modeStyle = modeStyle.Foreground(blue)
|
||||
}
|
||||
|
||||
modeIndicator := modeStyle.Render(fmt.Sprintf("[%s mode]", m.currentMode))
|
||||
titleText := m.styles.title.Render(m.title)
|
||||
fullTitle := fmt.Sprintf("%s %s", modeIndicator, titleText)
|
||||
parts = append(parts, fullTitle)
|
||||
|
||||
// Render based on input type
|
||||
switch m.inputType {
|
||||
case InputTypeMessage, InputTypeFeedback:
|
||||
parts = append(parts, m.textarea.View())
|
||||
|
||||
case InputTypeApproval:
|
||||
var options []string
|
||||
for i, option := range m.approvalOptions {
|
||||
if i == m.selectedOption {
|
||||
options = append(options, m.styles.selector.Render("")+m.styles.selectedOption.Render(option))
|
||||
} else {
|
||||
options = append(options, " "+m.styles.option.Render(option))
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Join(options, "\n"))
|
||||
}
|
||||
|
||||
// Wrap everything in the base style with border
|
||||
content := strings.Join(parts, "\n")
|
||||
rendered := m.styles.base.Render(content)
|
||||
|
||||
// Add newline before the form (outside the border)
|
||||
rendered = "\n" + rendered
|
||||
|
||||
// Track height for cleanup
|
||||
m.lastHeight = lipgloss.Height(rendered)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// ClearScreen returns the ANSI codes to clear the input from the terminal
|
||||
// This is used when submitting to remove the form cleanly
|
||||
func (m *InputModel) ClearScreen() string {
|
||||
if m.lastHeight == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Move cursor up by lastHeight lines and clear from cursor to end of screen
|
||||
return fmt.Sprintf("\033[%dA\033[J", m.lastHeight)
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the InputModel with all state preserved
|
||||
func (m *InputModel) Clone() *InputModel {
|
||||
// Create new textarea with same configuration
|
||||
ta := textarea.New()
|
||||
ta.SetValue(m.textarea.Value())
|
||||
ta.Placeholder = m.placeholder
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.SetHeight(5)
|
||||
ta.SetWidth(INPUT_WIDTH)
|
||||
ta.Focus()
|
||||
|
||||
// Configure keybindings
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply styles (including mode-based cursor color)
|
||||
cursorColor := lipgloss.Color("3") // Yellow for plan
|
||||
if m.currentMode == "act" {
|
||||
cursorColor = lipgloss.Color("39") // Blue for act
|
||||
}
|
||||
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.Placeholder = m.styles.placeholder
|
||||
ta.FocusedStyle.Text = m.styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle()
|
||||
ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor)
|
||||
ta.Cursor.TextStyle = m.styles.textArea
|
||||
|
||||
// Create cloned model
|
||||
clone := &InputModel{
|
||||
textarea: ta,
|
||||
suspended: false, // New program starts unsuspended
|
||||
savedValue: m.savedValue,
|
||||
inputType: m.inputType,
|
||||
title: m.title,
|
||||
placeholder: m.placeholder,
|
||||
currentMode: m.currentMode,
|
||||
width: m.width,
|
||||
lastHeight: m.lastHeight,
|
||||
approvalOptions: m.approvalOptions,
|
||||
selectedOption: m.selectedOption,
|
||||
pendingApproval: m.pendingApproval, // Preserve approval decision
|
||||
styles: m.styles,
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// openEditor opens an external editor for composing the message
|
||||
func (m *InputModel) openEditor() tea.Cmd {
|
||||
// Get editor from environment or use nano as default
|
||||
editorCmd := "nano"
|
||||
editorArgs := []string{}
|
||||
|
||||
if editor := os.Getenv("EDITOR"); editor != "" {
|
||||
editorFields := strings.Fields(editor)
|
||||
if len(editorFields) > 0 {
|
||||
editorCmd = editorFields[0]
|
||||
if len(editorFields) > 1 {
|
||||
editorArgs = editorFields[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create temp file with current content
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "*.md")
|
||||
if err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Write current textarea value to temp file
|
||||
if err := os.WriteFile(tmpFile.Name(), []byte(m.textarea.Value()), 0o644); err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the editor
|
||||
cmd := exec.Command(editorCmd, append(editorArgs, tmpFile.Name())...)
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||
content, readErr := os.ReadFile(tmpFile.Name())
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
|
||||
if readErr != nil {
|
||||
return editorFinishedMsg{err: readErr}
|
||||
}
|
||||
|
||||
return editorFinishedMsg{content: content, err: err}
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
@@ -60,7 +60,7 @@ func NewLockManager(clineDir string) (*LockManager, error) {
|
||||
}
|
||||
|
||||
// Database exists - open it normally (no schema creation)
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
// If we can't open existing database, return nil db manager
|
||||
return &LockManager{dbPath: dbPath, db: nil}, nil
|
||||
@@ -92,7 +92,7 @@ func (lm *LockManager) ensureConnection() error {
|
||||
}
|
||||
|
||||
// Database exists, try to connect
|
||||
db, err := sql.Open("sqlite3", lm.dbPath)
|
||||
db, err := sql.Open("sqlite", lm.dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
// 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, nil
|
||||
return false, err
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
|
||||
+343
-112
@@ -2,16 +2,33 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/config"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/cli/pkg/cli/updater"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TaskOptions contains options for creating a task
|
||||
type TaskOptions struct {
|
||||
Images []string
|
||||
Files []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
@@ -21,12 +38,13 @@ func NewTaskCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(newTaskPauseCommand())
|
||||
cmd.AddCommand(newTaskChatCommand())
|
||||
cmd.AddCommand(newTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
cmd.AddCommand(newTaskOpenCommand())
|
||||
cmd.AddCommand(newTaskRestoreCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -47,7 +65,7 @@ func ensureTaskManager(ctx context.Context, address string) error {
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
@@ -78,38 +96,14 @@ func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance exists
|
||||
func ensureDefaultInstance(ctx context.Context) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Set the new instance as default
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
wait bool
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -121,6 +115,12 @@ func newTaskNewCommand() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for creating tasks")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
@@ -142,22 +142,27 @@ func newTaskNewCommand() *cobra.Command {
|
||||
if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
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)
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Wait for completion if requested
|
||||
if wait {
|
||||
fmt.Println("Following task conversation...")
|
||||
return taskManager.FollowConversation(ctx)
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -166,21 +171,22 @@ func newTaskNewCommand() *cobra.Command {
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion")
|
||||
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)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
func newTaskPauseCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Cancel the current task",
|
||||
Use: "pause",
|
||||
Aliases: []string{"p"},
|
||||
Short: "Pause the current task",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
@@ -192,7 +198,7 @@ func newTaskCancelCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Task cancelled successfully")
|
||||
fmt.Println("Task paused successfully")
|
||||
fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance())
|
||||
return nil
|
||||
},
|
||||
@@ -208,7 +214,9 @@ func newTaskSendCommand() *cobra.Command {
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
approve bool
|
||||
deny bool
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -220,22 +228,28 @@ func newTaskSendCommand() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for sending messages")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 message == "" && len(images) == 0 && len(files) == 0 && mode == "" && !approve && !deny {
|
||||
return fmt.Errorf("content (message, files, images) required unless using --mode, --approve, or --deny flags")
|
||||
}
|
||||
|
||||
if approve != "" && approve != "true" && approve != "false" {
|
||||
return fmt.Errorf("--approve must be 'true' or 'false'")
|
||||
if approve && deny {
|
||||
return fmt.Errorf("cannot use both --approve and --deny flags")
|
||||
}
|
||||
|
||||
if approve != "" && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve and --mode together")
|
||||
if (approve || deny) && mode != "" {
|
||||
return fmt.Errorf("cannot use --approve/--deny and --mode together")
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
@@ -243,15 +257,38 @@ func newTaskSendCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sendDisabled, err := taskManager.CheckSendDisabled(ctx)
|
||||
|
||||
// Check if we can send a message
|
||||
err = taskManager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, task.ErrNoActiveTask) {
|
||||
fmt.Println("Cannot send message: no active task")
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, task.ErrTaskBusy) {
|
||||
fmt.Println("Cannot send message: task is currently busy")
|
||||
return nil
|
||||
}
|
||||
// All other errors are unexpected
|
||||
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
|
||||
// Process yolo flag and apply settings
|
||||
if yolo {
|
||||
settings := []string{"yolo_mode_toggled=true"}
|
||||
parsedSettings, secrets, err := task.ParseTaskSettings(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
if err := configManager.UpdateSettings(ctx, parsedSettings, secrets); err != nil {
|
||||
return fmt.Errorf("failed to apply settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if mode != "" {
|
||||
@@ -261,7 +298,16 @@ func newTaskSendCommand() *cobra.Command {
|
||||
fmt.Printf("Mode set to %s and message sent successfully.\n", mode)
|
||||
|
||||
} else {
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil {
|
||||
// Convert approve/deny booleans to string
|
||||
approveStr := ""
|
||||
if approve {
|
||||
approveStr = "true"
|
||||
}
|
||||
if deny {
|
||||
approveStr = "false"
|
||||
}
|
||||
|
||||
if err := taskManager.SendMessage(ctx, message, images, files, approveStr); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Message sent successfully.\n")
|
||||
@@ -276,19 +322,22 @@ func newTaskSendCommand() *cobra.Command {
|
||||
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")
|
||||
cmd.Flags().BoolVarP(&approve, "approve", "a", false, "approve pending request")
|
||||
cmd.Flags().BoolVarP(&deny, "deny", "d", false, "deny pending request")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskFollowCommand() *cobra.Command {
|
||||
func newTaskChatCommand() *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.`,
|
||||
Use: "chat",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Chat with the current task in interactive mode",
|
||||
Long: `Chat with the current task, displaying messages in real-time with interactive input enabled.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
@@ -297,9 +346,19 @@ func newTaskFollowCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
// Check if there's an active task before entering follow mode
|
||||
err := taskManager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, task.ErrNoActiveTask) {
|
||||
fmt.Println("No active task found. Use 'cline task new' to create a task first.")
|
||||
return nil
|
||||
}
|
||||
// For other errors (like task busy), we can still enter follow mode
|
||||
// as the user may want to observe the task
|
||||
}
|
||||
|
||||
return taskManager.FollowConversation(ctx)
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -310,16 +369,16 @@ func newTaskFollowCommand() *cobra.Command {
|
||||
|
||||
func newTaskViewCommand() *cobra.Command {
|
||||
var (
|
||||
current bool
|
||||
summary bool
|
||||
address string
|
||||
follow bool
|
||||
followComplete 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.`,
|
||||
Long: `Output conversation snapshot by default, or follow with flags.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
@@ -330,26 +389,27 @@ func newTaskViewCommand() *cobra.Command {
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
if current {
|
||||
return taskManager.ShowConversation(ctx)
|
||||
} else if summary {
|
||||
return taskManager.GatherFinalSummary(ctx)
|
||||
} else {
|
||||
if follow {
|
||||
// Follow conversation forever (non-interactive)
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), false)
|
||||
} else if followComplete {
|
||||
// Follow until completion
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
} else {
|
||||
// Default: show snapshot
|
||||
return taskManager.ShowConversation(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().BoolVarP(&follow, "follow", "f", false, "follow conversation forever")
|
||||
cmd.Flags().BoolVarP(&followComplete, "follow-complete", "c", false, "follow until completion")
|
||||
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"},
|
||||
@@ -357,31 +417,27 @@ func newTaskListCommand() *cobra.Command {
|
||||
Long: `Display recent tasks from task history.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ListTasks(ctx)
|
||||
// Read directly from disk
|
||||
return task.ListTasksFromDisk()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskResumeCommand() *cobra.Command {
|
||||
var address string
|
||||
func newTaskOpenCommand() *cobra.Command {
|
||||
var (
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "resume <task-id>",
|
||||
Aliases: []string{"r"},
|
||||
Short: "Resume a task by ID",
|
||||
Long: `Resume an existing task by ID.`,
|
||||
Use: "open <task-id>",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Open a task by ID",
|
||||
Long: `Open an existing task by ID and optionally update settings or mode.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
@@ -394,11 +450,128 @@ func newTaskResumeCommand() *cobra.Command {
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.ResumeTask(ctx, taskID)
|
||||
// Resume the task
|
||||
if err := taskManager.ResumeTask(ctx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Process yolo flag and apply settings
|
||||
if yolo {
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
if len(settings) > 0 {
|
||||
// Parse settings using existing parser
|
||||
parsedSettings, secrets, err := task.ParseTaskSettings(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
// Apply task-specific settings using UpdateTaskSettings RPC
|
||||
if parsedSettings != nil {
|
||||
_, err = taskManager.GetClient().State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{
|
||||
Settings: parsedSettings,
|
||||
TaskId: &taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply task settings: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Task-specific settings applied successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle secrets separately if provided (they must go to global config)
|
||||
if secrets != nil {
|
||||
// Secrets are always global, not task-specific
|
||||
configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
if err := configManager.UpdateSettings(ctx, nil, secrets); err != nil {
|
||||
return fmt.Errorf("failed to apply secrets: %w", err)
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Global secrets applied successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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 model=claude)")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -419,17 +592,20 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
|
||||
// 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(" ")
|
||||
// Only try to read if there's actually data available
|
||||
if stat.Size() > 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)
|
||||
}
|
||||
content.WriteString(stdinContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,3 +618,58 @@ func CleanupTaskManager() {
|
||||
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.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)
|
||||
}
|
||||
|
||||
// Check for updates in background after task is created
|
||||
updater.CheckAndUpdate(opts.Verbose)
|
||||
|
||||
// If yolo mode is enabled, follow until completion (non-interactive)
|
||||
// Otherwise, follow in interactive mode
|
||||
if opts.Yolo {
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
} else {
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ListTasksFromDisk reads task history directly from disk
|
||||
func ListTasksFromDisk() error {
|
||||
// Get the task history file path
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(homeDir, ".cline", "data", "state", "taskHistory.json")
|
||||
|
||||
// Read the file
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read task history: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON into intermediate struct
|
||||
var historyItems []types.HistoryItem
|
||||
if err := json.Unmarshal(data, &historyItems); err != nil {
|
||||
return fmt.Errorf("failed to parse task history: %w", err)
|
||||
}
|
||||
|
||||
if len(historyItems) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by timestamp ascending (oldest first, newest last)
|
||||
sort.Slice(historyItems, func(i, j int) bool {
|
||||
return historyItems[i].Ts < historyItems[j].Ts
|
||||
})
|
||||
|
||||
// Convert to protobuf TaskItem format for rendering
|
||||
tasks := make([]*cline.TaskItem, len(historyItems))
|
||||
for i, item := range historyItems {
|
||||
tasks[i] = &cline.TaskItem{
|
||||
Id: item.Id,
|
||||
Task: item.Task,
|
||||
Ts: item.Ts,
|
||||
IsFavorited: item.IsFavorited,
|
||||
Size: item.Size,
|
||||
TotalCost: item.TotalCost,
|
||||
TokensIn: item.TokensIn,
|
||||
TokensOut: item.TokensOut,
|
||||
CacheWrites: item.CacheWrites,
|
||||
CacheReads: item.CacheReads,
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing renderer
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
return renderer.RenderTaskList(tasks)
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"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
|
||||
program *tea.Program
|
||||
programRunning bool
|
||||
programDoneChan chan struct{} // Signals when program actually exits
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
feedbackApproval bool // Track if we're in feedback after approval
|
||||
feedbackApproved bool // Track the approval decision
|
||||
approvalMessage *types.ClineMessage // Store the approval message for determining action
|
||||
ctx context.Context // Context for restart callback
|
||||
}
|
||||
|
||||
// NewInputHandler creates a new input handler
|
||||
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
|
||||
return &InputHandler{
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
resultChan: make(chan output.InputSubmitMsg, 1),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
}()
|
||||
|
||||
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 {
|
||||
output.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
output.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 {
|
||||
output.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
output.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
|
||||
err = ih.manager.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, ErrNoActiveTask) {
|
||||
// No active task - don't show input prompt
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, ErrTaskBusy) {
|
||||
// Task is busy - don't show input prompt
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
continue
|
||||
}
|
||||
// Unexpected error
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we reach here, we can send a message
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
output.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 {
|
||||
// Create styles for mode switch messages (respect global color profile)
|
||||
actStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
planStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
|
||||
|
||||
if remainingMessage != "" {
|
||||
// Switching with a message - behavior differs by mode
|
||||
if newMode == "act" {
|
||||
// Act mode: can send mode + message in one call
|
||||
if err := ih.manager.SetMode(ctx, newMode, &remainingMessage, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to act mode with message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
output.Printf("\n%s\n", actStyle.Render("Switched to act mode"))
|
||||
} else {
|
||||
// Plan mode: must switch first, then send message separately
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to plan mode: %v\n", err)
|
||||
continue
|
||||
}
|
||||
output.Printf("\n%s\n", planStyle.Render("Switched to plan mode"))
|
||||
|
||||
// Now send the message separately
|
||||
time.Sleep(500 * time.Millisecond) // Give mode switch time to process
|
||||
if err := ih.manager.SendMessage(ctx, remainingMessage, nil, nil, ""); err != nil {
|
||||
output.Printf("\nError sending message after mode switch: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just switch mode, no message
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
output.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
// Color based on mode
|
||||
if newMode == "act" {
|
||||
output.Printf("\n%s\n", actStyle.Render("Switched to act mode"))
|
||||
} else {
|
||||
output.Printf("\n%s\n", planStyle.Render("Switched to plan mode"))
|
||||
}
|
||||
}
|
||||
|
||||
// Mode switch handled, continue to next poll
|
||||
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 {
|
||||
output.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determineAutoApprovalAction determines which auto-approval action to enable based on the ask type
|
||||
func determineAutoApprovalAction(msg *types.ClineMessage) (string, error) {
|
||||
switch types.AskType(msg.Ask) {
|
||||
case types.AskTypeTool:
|
||||
// Parse tool message to determine if it's a read or edit operation
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return "", fmt.Errorf("failed to parse tool message: %w", err)
|
||||
}
|
||||
|
||||
// Determine action based on tool type
|
||||
switch types.ToolType(toolMsg.Tool) {
|
||||
case types.ToolTypeReadFile,
|
||||
types.ToolTypeListFilesTopLevel,
|
||||
types.ToolTypeListFilesRecursive,
|
||||
types.ToolTypeListCodeDefinitionNames,
|
||||
types.ToolTypeSearchFiles,
|
||||
types.ToolTypeWebFetch:
|
||||
return "read_files", nil
|
||||
case types.ToolTypeEditedExistingFile,
|
||||
types.ToolTypeNewFileCreated:
|
||||
return "edit_files", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported tool type: %s", toolMsg.Tool)
|
||||
}
|
||||
|
||||
case types.AskTypeCommand:
|
||||
return "execute_all_commands", nil
|
||||
|
||||
case types.AskTypeBrowserActionLaunch:
|
||||
return "use_browser", nil
|
||||
|
||||
case types.AskTypeUseMcpServer:
|
||||
return "use_mcp", nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported ask type: %s", msg.Ask)
|
||||
}
|
||||
}
|
||||
|
||||
// promptForInput displays an interactive prompt and waits for user input
|
||||
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
model := output.NewInputModel(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message...",
|
||||
"/plan or /act to switch modes\nctrl+e to open editor",
|
||||
currentMode,
|
||||
)
|
||||
|
||||
return ih.runInputProgram(ctx, model)
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Store the approval message for later use in determining auto-approval action
|
||||
ih.approvalMessage = msg
|
||||
|
||||
model := output.NewInputModel(
|
||||
output.InputTypeApproval,
|
||||
"Let Cline use this tool?",
|
||||
"",
|
||||
ih.manager.GetCurrentMode(),
|
||||
)
|
||||
|
||||
message, shouldSend, err := ih.runInputProgram(ctx, model)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
if !shouldSend {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// The approval and feedback are handled via the model state
|
||||
return ih.feedbackApproved, message, nil
|
||||
}
|
||||
|
||||
// runInputProgram runs the bubbletea program and waits for result
|
||||
func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputModel) (string, bool, error) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Create the program with custom update wrapper
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: &model,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
ih.ctx = ctx
|
||||
|
||||
// Set up coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(wrappedModel.model)
|
||||
output.SetRestartCallback(ih.restartProgram)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run program in goroutine
|
||||
programErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
programErrChan <- err
|
||||
}
|
||||
// Signal that program is done
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
|
||||
// Wait for result, cancellation, or context done
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, ctx.Err()
|
||||
|
||||
case <-ih.cancelChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, context.Canceled
|
||||
|
||||
case err := <-programErrChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, err
|
||||
|
||||
case result := <-ih.resultChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Handle different input types
|
||||
switch result.InputType {
|
||||
case output.InputTypeMessage:
|
||||
if result.Value == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return result.Value, true, nil
|
||||
|
||||
case output.InputTypeApproval:
|
||||
if result.NeedsFeedback {
|
||||
// Need to collect feedback - will be handled by model state change
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Check if NoAskAgain was selected
|
||||
if result.NoAskAgain && result.Approved && ih.approvalMessage != nil {
|
||||
// Determine which auto-approval action to enable
|
||||
action, err := determineAutoApprovalAction(ih.approvalMessage)
|
||||
if err != nil {
|
||||
output.Printf("\nWarning: Could not determine auto-approval action: %v\n", err)
|
||||
} else {
|
||||
// Enable the auto-approval action
|
||||
if err := ih.manager.UpdateTaskAutoApprovalAction(ctx, action); err != nil {
|
||||
output.Printf("\nWarning: Could not update auto-approval: %v\n", err)
|
||||
} else {
|
||||
output.Printf("\nAuto-approval enabled for %s\n", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store approval state for when feedback comes back
|
||||
ih.feedbackApproval = false
|
||||
ih.feedbackApproved = result.Approved
|
||||
return "", true, nil
|
||||
|
||||
case output.InputTypeFeedback:
|
||||
// This came from approval flow
|
||||
ih.feedbackApproval = true
|
||||
ih.feedbackApproved = result.Approved // Use the approval decision from the feedback
|
||||
return result.Value, true, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// inputProgramWrapper wraps the InputModel to handle message routing
|
||||
type inputProgramWrapper struct {
|
||||
model *output.InputModel
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
handler *InputHandler
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Init() tea.Cmd {
|
||||
return w.model.Init()
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case output.InputSubmitMsg:
|
||||
// Handle input submission - clear the screen before quitting
|
||||
w.resultChan <- msg
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.InputCancelMsg:
|
||||
// Handle cancellation - clear the screen before quitting
|
||||
w.cancelChan <- struct{}{}
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.ChangeInputTypeMsg:
|
||||
// Change input type (approval -> feedback)
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
// Forward to wrapped model
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
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 {
|
||||
output.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
output.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
output.Println("\nExiting follow mode...")
|
||||
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()
|
||||
}
|
||||
if ih.program != nil && ih.programRunning {
|
||||
ih.program.Quit()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// restartProgram restarts the Bubble Tea program with preserved state
|
||||
func (ih *InputHandler) restartProgram(savedModel *output.InputModel) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Wait for old program to actually quit
|
||||
if ih.programDoneChan != nil {
|
||||
select {
|
||||
case <-ih.programDoneChan:
|
||||
// Program quit successfully
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Timeout - continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Create new wrapper with the saved model
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: savedModel,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
// Start new program
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
|
||||
// Update coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(savedModel)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run in goroutine
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
// Log error if needed
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Program restart error: %v\n", err)
|
||||
}
|
||||
}
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
}
|
||||
+454
-116
@@ -3,8 +3,12 @@ package task
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
@@ -15,6 +19,12 @@ import (
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// Sentinel errors for CheckSendEnabled
|
||||
var (
|
||||
ErrNoActiveTask = fmt.Errorf("no active task")
|
||||
ErrTaskBusy = fmt.Errorf("task is currently busy")
|
||||
)
|
||||
|
||||
// Manager handles task execution and message display
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
@@ -22,14 +32,21 @@ type Manager struct {
|
||||
clientAddress string
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
toolRenderer *display.ToolRenderer
|
||||
systemRenderer *display.SystemMessageRenderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
isStreamingMode bool
|
||||
isInteractive bool
|
||||
currentMode string // "plan" or "act"
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager
|
||||
func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
renderer := display.NewRenderer()
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
toolRenderer := display.NewToolRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
|
||||
systemRenderer := display.NewSystemMessageRenderer(renderer, renderer.GetMdRenderer(), global.Config.OutputFormat)
|
||||
streamingDisplay := display.NewStreamingDisplay(state, renderer)
|
||||
|
||||
// Create handler registry and register handlers
|
||||
@@ -42,8 +59,11 @@ func NewManager(client *client.ClineClient) *Manager {
|
||||
clientAddress: "", // Will be set when client is provided
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
toolRenderer: toolRenderer,
|
||||
systemRenderer: systemRenderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
currentMode: "plan", // Default mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +126,7 @@ func (m *Manager) GetCurrentInstance() string {
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) {
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, settingsFlags []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -118,8 +138,8 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [
|
||||
if len(images) > 0 {
|
||||
m.renderer.RenderDebug("Images: %v", images)
|
||||
}
|
||||
if len(workspacePaths) > 0 {
|
||||
m.renderer.RenderDebug("Workspaces: %v", workspacePaths)
|
||||
if len(settingsFlags) > 0 {
|
||||
m.renderer.RenderDebug("Settings: %v", settingsFlags)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,11 +148,22 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [
|
||||
return "", fmt.Errorf("failed to cancel existing task: %w", err)
|
||||
}
|
||||
|
||||
// Parse task settings if provided
|
||||
var taskSettings *cline.Settings
|
||||
if len(settingsFlags) > 0 {
|
||||
var err error
|
||||
taskSettings, _, err = ParseTaskSettings(settingsFlags)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse task settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create task request
|
||||
req := &cline.NewTaskRequest{
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
TaskSettings: taskSettings,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.NewTask(ctx, req)
|
||||
@@ -184,26 +215,64 @@ func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
|
||||
fmt.Println("Cancelled existing task to start new one")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
// ValidateCheckpointExists checks if a checkpoint ID is valid
|
||||
func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int64) error {
|
||||
// Get current state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get latest state: %w", err)
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
// Find and validate the checkpoint message
|
||||
for _, msg := range messages {
|
||||
if msg.Timestamp == checkpointID {
|
||||
if msg.Say != string(types.SayTypeCheckpointCreated) {
|
||||
return fmt.Errorf("timestamp %d is not a checkpoint (type: %s)", checkpointID, msg.Type)
|
||||
}
|
||||
return nil // Valid checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID)
|
||||
}
|
||||
|
||||
// CheckSendEnabled checks if we can send a message to the current task
|
||||
// Returns nil if sending is allowed, or an error indicating why it's not allowed
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendEnabled(ctx context.Context) error {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
var stateData types.ExtensionState
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state: %w", err)
|
||||
}
|
||||
|
||||
// Check if there is an active task
|
||||
if stateData.CurrentTaskItem == nil {
|
||||
return ErrNoActiveTask
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to extract messages: %w", err)
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use final message to perform validation
|
||||
@@ -233,15 +302,25 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: task is streaming and non-error")
|
||||
}
|
||||
return true, nil
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
// All ask messages allow sending
|
||||
// All ask messages allow sending, EXCEPT command_output
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
// Special case: command_output means command is actively streaming
|
||||
// In the CLI, we don't want to show input during streaming output (too messy)
|
||||
// The webview can show "Proceed While Running" button, but CLI should wait
|
||||
if lastMessage.Ask == string(types.AskTypeCommandOutput) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: command output is streaming")
|
||||
}
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send enabled: ask message")
|
||||
}
|
||||
return false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Technically unnecessary but implements getButtonConfig 1-1
|
||||
@@ -249,14 +328,58 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: API request is active")
|
||||
}
|
||||
return true, nil
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: default fallback")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
// CheckNeedsApproval determines if the current task is waiting for approval
|
||||
// Returns (needsApproval, lastMessage, error)
|
||||
func (m *Manager) CheckNeedsApproval(ctx context.Context) (bool, *types.ClineMessage, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Use final message to check if approval is needed
|
||||
lastMessage := messages[len(messages)-1]
|
||||
|
||||
// Only check non-partial ask messages
|
||||
if lastMessage.Partial {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Check if this is an approval-required ask type
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
approvalTypes := []string{
|
||||
string(types.AskTypeTool),
|
||||
string(types.AskTypeCommand),
|
||||
string(types.AskTypeBrowserActionLaunch),
|
||||
string(types.AskTypeUseMcpServer),
|
||||
}
|
||||
|
||||
for _, approvalType := range approvalTypes {
|
||||
if lastMessage.Ask == approvalType {
|
||||
return true, lastMessage, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a followup message to the current task
|
||||
@@ -449,6 +572,27 @@ func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreCheckpoint restores the task to a specific checkpoint
|
||||
func (m *Manager) RestoreCheckpoint(ctx context.Context, checkpointID int64, restoreType string) error {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Restoring checkpoint: %d (type: %s)", checkpointID, restoreType)
|
||||
}
|
||||
|
||||
// Create the checkpoint restore request
|
||||
req := &cline.CheckpointRestoreRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Number: checkpointID,
|
||||
RestoreType: restoreType,
|
||||
}
|
||||
|
||||
_, err := m.client.Checkpoints.CheckpointRestore(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint %d: %w", checkpointID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTask cancels the current task
|
||||
func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
@@ -462,60 +606,24 @@ func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks retrieves and displays task history
|
||||
func (m *Manager) ListTasks(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
req := &cline.GetTaskHistoryRequest{
|
||||
FavoritesOnly: false,
|
||||
SearchQuery: "",
|
||||
SortBy: "oldest",
|
||||
CurrentWorkspaceOnly: false,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.GetTaskHistory(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task history: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Tasks) == 0 {
|
||||
fmt.Println("No task history found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.renderer.RenderTaskList(resp.Tasks)
|
||||
}
|
||||
|
||||
// GatherFinalSummary attempts to gather the latest completion_result output and display it
|
||||
func (m *Manager) GatherFinalSummary(ctx context.Context) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
|
||||
// Check if this is a completion result SAY message
|
||||
if msg.IsSay() && msg.Say == string(types.SayTypeCompletionResult) {
|
||||
return m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShowConversation displays the current conversation
|
||||
func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
// Check if there's an active task before showing conversation
|
||||
err := m.CheckSendEnabled(ctx)
|
||||
if err != nil {
|
||||
// Handle specific error cases
|
||||
if errors.Is(err, ErrNoActiveTask) {
|
||||
fmt.Println("No active task found. Use 'cline task new' to create a task first.")
|
||||
return nil
|
||||
}
|
||||
// For other errors (like task busy), we can still show the conversation
|
||||
}
|
||||
|
||||
// Disable streaming mode for static view
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = false
|
||||
m.mu.Unlock()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -536,16 +644,35 @@ func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display messages
|
||||
for i, msg := range messages {
|
||||
if msg.Partial {
|
||||
continue
|
||||
}
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string, interactive bool) error {
|
||||
// Enable streaming mode
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = true
|
||||
m.isInteractive = interactive
|
||||
m.mu.Unlock()
|
||||
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Using instance: %s*\n*Press Ctrl+C to exit*", instanceAddress)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
} else {
|
||||
fmt.Printf("Using instance: %s\n", instanceAddress)
|
||||
if interactive {
|
||||
fmt.Println("Following task conversation in interactive mode... (Press Ctrl+C to exit)")
|
||||
} else {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -561,21 +688,64 @@ func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
fmt.Println("\n--- Live updates ---")
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
errChan := make(chan error, 3)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
|
||||
// Start input handler if interactive mode is enabled
|
||||
if interactive {
|
||||
inputHandler := NewInputHandler(m, coordinator, cancel)
|
||||
go inputHandler.Start(ctx, errChan)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ctrl+C signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
defer signal.Stop(sigChan) // Clean up signal handler when goroutine exits
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sigChan:
|
||||
if interactive {
|
||||
// Interactive mode (task chat)
|
||||
// Check if input is currently being shown
|
||||
if coordinator.IsInputAllowed() {
|
||||
// Input form is showing - huh will handle the signal via ErrUserAborted
|
||||
// Do nothing here, let the input handler deal with it
|
||||
} else {
|
||||
// Streaming mode - cancel the task and stay in follow mode
|
||||
m.renderer.RenderTaskCancelled()
|
||||
if err := m.CancelTask(context.Background()); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
}
|
||||
// Don't cancel main context - stay in follow mode
|
||||
}
|
||||
} else {
|
||||
// Non-interactive mode (task view --follow)
|
||||
// Just exit without canceling the task
|
||||
cancel()
|
||||
return // Exit the loop after canceling in non-interactive mode
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for either stream to error or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Check if this was a user-initiated cancellation (Ctrl+C)
|
||||
// Return nil for clean exit instead of context.Canceled error
|
||||
if ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
@@ -585,7 +755,12 @@ func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
|
||||
// FollowConversationUntilCompletion streams conversation updates until task completion
|
||||
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)")
|
||||
// Enable streaming mode
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = true
|
||||
m.mu.Unlock()
|
||||
|
||||
fmt.Println("Following task conversation until completion... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -593,10 +768,10 @@ func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Get current message count without displaying history
|
||||
totalMessageCount, err := m.getCurrentMessageCount(ctx)
|
||||
// Load history first
|
||||
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err)
|
||||
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
@@ -726,6 +901,9 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat
|
||||
|
||||
// processStateUpdate processes state updates and supports logic for handling task competion markers
|
||||
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
// Update current mode from state
|
||||
m.updateMode(stateUpdate.StateJson)
|
||||
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -749,27 +927,96 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Currently handling a subset of message types for displaying
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
if !coordinator.IsProcessedInCurrentTurn("user_msg") {
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("user_msg")
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCommand):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeBrowserActionLaunch):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeMcpServerRequestStarted):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
if !coordinator.IsProcessedInCurrentTurn("checkpoint") {
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("checkpoint")
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
case msg.Ask == string(types.AskTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Ask == string(types.AskTypePlanModeRespond):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
// Non-streaming mode: render normally when message is complete
|
||||
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Type == types.MessageTypeAsk:
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
// Only render if not already handled by partial stream
|
||||
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,6 +1037,10 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
m.streamingDisplay.FreezeActiveSegment()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -810,7 +1061,7 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
msg.Type, msg.Partial, len(msg.Text))
|
||||
|
||||
// Handle the message with streaming support for de-dupping
|
||||
if err := m.handleStreamingMessage(msg); err != nil {
|
||||
if err := m.handleStreamingMessage(msg, coordinator); err != nil {
|
||||
m.renderer.RenderDebug("Error handling streaming message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -818,7 +1069,7 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
}
|
||||
|
||||
// handleStreamingMessage handles a streaming message
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *StreamCoordinator) error {
|
||||
// Debug: Always log what we're processing
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
@@ -827,7 +1078,7 @@ func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
return m.displayMessage(msg, true, false, -1)
|
||||
m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -846,12 +1097,21 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
|
||||
if global.Config.OutputFormat == "json" {
|
||||
return m.outputMessageAsJSON(msg)
|
||||
} else {
|
||||
m.mu.RLock()
|
||||
isStreaming := m.isStreamingMode
|
||||
isInteractive := m.isInteractive
|
||||
m.mu.RUnlock()
|
||||
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
ToolRenderer: m.toolRenderer,
|
||||
SystemRenderer: m.systemRenderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
IsStreamingMode: isStreaming,
|
||||
IsInteractive: isInteractive,
|
||||
}
|
||||
|
||||
return m.handlerRegistry.Handle(msg, dc)
|
||||
@@ -869,21 +1129,6 @@ func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentMessageCount gets the current message count without displaying messages
|
||||
func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
|
||||
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
|
||||
// Get the latest state which contains messages
|
||||
@@ -910,16 +1155,30 @@ func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error)
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Conversation history (%d of %d messages)*", maxHistoryMessages, totalMessages)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Conversation history (%d messages)*", totalMessages)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// Display recent messages
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
// Display the message
|
||||
if msg.Partial {
|
||||
continue
|
||||
}
|
||||
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
@@ -937,6 +1196,85 @@ func (m *Manager) GetState() *types.ConversationState {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// GetClient returns the underlying ClineClient for direct gRPC calls
|
||||
func (m *Manager) GetClient() *client.ClineClient {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.client
|
||||
}
|
||||
|
||||
// GetRenderer returns the renderer for formatting output
|
||||
func (m *Manager) GetRenderer() *display.Renderer {
|
||||
return m.renderer
|
||||
}
|
||||
|
||||
// GetCurrentMode returns the current plan/act mode
|
||||
func (m *Manager) GetCurrentMode() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentMode
|
||||
}
|
||||
|
||||
// extractModeFromState extracts the current mode from state JSON
|
||||
func (m *Manager) extractModeFromState(stateJson string) string {
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return m.currentMode // Return current mode if parsing fails
|
||||
}
|
||||
|
||||
if mode, ok := rawState["mode"].(string); ok {
|
||||
return mode
|
||||
}
|
||||
|
||||
return m.currentMode // Return current mode if not found in state
|
||||
}
|
||||
|
||||
// updateMode updates the current mode from state
|
||||
func (m *Manager) updateMode(stateJson string) {
|
||||
mode := m.extractModeFromState(stateJson)
|
||||
m.mu.Lock()
|
||||
m.currentMode = mode
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UpdateTaskAutoApprovalAction enables a specific auto-approval action for the current task
|
||||
func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey string) error {
|
||||
settings := &cline.Settings{
|
||||
AutoApprovalSettings: &cline.AutoApprovalSettings{
|
||||
Enabled: true,
|
||||
MaxRequests: 20, // Important: avoid maxRequests=0 bug
|
||||
Actions: &cline.AutoApprovalActions{},
|
||||
},
|
||||
}
|
||||
|
||||
// Set the specific action to true based on actionKey
|
||||
truePtr := func() *bool { b := true; return &b }()
|
||||
|
||||
switch actionKey {
|
||||
case "read_files":
|
||||
settings.AutoApprovalSettings.Actions.ReadFiles = truePtr
|
||||
case "edit_files":
|
||||
settings.AutoApprovalSettings.Actions.EditFiles = truePtr
|
||||
case "execute_all_commands":
|
||||
settings.AutoApprovalSettings.Actions.ExecuteAllCommands = truePtr
|
||||
case "use_browser":
|
||||
settings.AutoApprovalSettings.Actions.UseBrowser = truePtr
|
||||
case "use_mcp":
|
||||
settings.AutoApprovalSettings.Actions.UseMcp = truePtr
|
||||
default:
|
||||
return fmt.Errorf("unknown auto-approval action: %s", actionKey)
|
||||
}
|
||||
|
||||
_, err := m.client.State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{
|
||||
Settings: settings,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update task settings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (m *Manager) Cleanup() {
|
||||
// Clean up streaming display resources if needed
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
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 "max_consecutive_mistakes":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MaxConsecutiveMistakes = 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 = boolPtr(val)
|
||||
case "read_files_externally":
|
||||
actions.ReadFilesExternally = boolPtr(val)
|
||||
case "edit_files":
|
||||
actions.EditFiles = boolPtr(val)
|
||||
case "edit_files_externally":
|
||||
actions.EditFilesExternally = boolPtr(val)
|
||||
case "execute_safe_commands":
|
||||
actions.ExecuteSafeCommands = boolPtr(val)
|
||||
case "execute_all_commands":
|
||||
actions.ExecuteAllCommands = boolPtr(val)
|
||||
case "use_browser":
|
||||
actions.UseBrowser = boolPtr(val)
|
||||
case "use_mcp":
|
||||
actions.UseMcp = boolPtr(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
|
||||
@@ -1,9 +1,13 @@
|
||||
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
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
@@ -34,8 +38,23 @@ func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn resets the coordinator for the next conversation turn
|
||||
// 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
|
||||
sc.processedInCurrentTurn = make(map[string]bool)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
)
|
||||
|
||||
// KeyboardProtocol manages enhanced keyboard protocol support for detecting
|
||||
// modified keys like shift+enter across all major terminals.
|
||||
type KeyboardProtocol struct {
|
||||
enabled bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var globalProtocol = &KeyboardProtocol{}
|
||||
|
||||
// EnableEnhancedKeyboard enables enhanced keyboard protocols to support
|
||||
// shift+enter and other modified keys across all major terminals:
|
||||
// - VS Code integrated terminal
|
||||
// - iTerm2
|
||||
// - Terminal.app
|
||||
// - Ghostty
|
||||
// - Kitty
|
||||
// - WezTerm
|
||||
// - Alacritty
|
||||
// - foot
|
||||
// - xterm
|
||||
//
|
||||
// This function is safe to call multiple times and handles cleanup automatically.
|
||||
// It enables both modifyOtherKeys (xterm protocol) and Kitty keyboard protocol
|
||||
// for maximum compatibility.
|
||||
func EnableEnhancedKeyboard() {
|
||||
globalProtocol.mu.Lock()
|
||||
defer globalProtocol.mu.Unlock()
|
||||
|
||||
if globalProtocol.enabled {
|
||||
return // Already enabled
|
||||
}
|
||||
|
||||
// Check if we're in a TTY (not piped/redirected)
|
||||
if !isatty(os.Stdin.Fd()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enable modifyOtherKeys mode 2
|
||||
// This tells xterm-compatible terminals (VS Code, iTerm2, Terminal.app, etc.)
|
||||
// to send escape sequences for modified keys including shift+enter
|
||||
// Format: CSI > 4 ; 2 m
|
||||
// - Mode 2 enables for ALL keys including well-known ones
|
||||
fmt.Print("\x1b[>4;2m")
|
||||
|
||||
// Also enable Kitty keyboard protocol for terminals that support it
|
||||
// This is a more modern protocol supported by Kitty, Ghostty, WezTerm, foot, etc.
|
||||
// Format: CSI = <flags> u where flags=1 means "disambiguate escape codes"
|
||||
// This makes shift+enter distinguishable from plain enter
|
||||
fmt.Print("\x1b[=1u")
|
||||
|
||||
globalProtocol.enabled = true
|
||||
}
|
||||
|
||||
// DisableEnhancedKeyboard restores the terminal to its default keyboard mode.
|
||||
// This should be called on program exit to be a good citizen.
|
||||
func DisableEnhancedKeyboard() {
|
||||
globalProtocol.mu.Lock()
|
||||
defer globalProtocol.mu.Unlock()
|
||||
|
||||
if !globalProtocol.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Disable modifyOtherKeys (restore to mode 0)
|
||||
fmt.Print("\x1b[>4;0m")
|
||||
|
||||
// Disable Kitty keyboard protocol
|
||||
fmt.Print("\x1b[<u")
|
||||
|
||||
globalProtocol.enabled = false
|
||||
}
|
||||
|
||||
// isatty checks if a file descriptor is a terminal
|
||||
func isatty(fd uintptr) bool {
|
||||
// Use the standard library's terminal package
|
||||
// This works across all platforms (Unix, Windows, etc.)
|
||||
fileInfo, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// SetupKeyboard detects the current terminal and configures keybindings if needed.
|
||||
// Runs in background and doesn't block. Prints status when configs are modified.
|
||||
func SetupKeyboard() {
|
||||
go func() {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
setupKeyboardInternal(renderer)
|
||||
}()
|
||||
}
|
||||
|
||||
// SetupKeyboardSync is the synchronous version used by doctor command.
|
||||
// Blocks until complete and prints status for all terminals.
|
||||
func SetupKeyboardSync() {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
setupKeyboardInternal(renderer)
|
||||
}
|
||||
|
||||
func setupKeyboardInternal(renderer *display.Renderer) {
|
||||
terminalName := DetectTerminal()
|
||||
|
||||
switch terminalName {
|
||||
case "vscode":
|
||||
// VS Code and Cursor use the same TERM_PROGRAM value
|
||||
modified, path := SetupVSCodeKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s VS Code %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ VS Code shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
modified, path = SetupCursorKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Cursor %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Cursor shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "ghostty":
|
||||
modified, path := SetupGhosttyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Ghostty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
fmt.Printf("%s\n", renderer.Dim(" Fully restart Ghostty (quit all windows) for changes to take effect"))
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Ghostty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "wezterm":
|
||||
modified, path := SetupWezTermKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s WezTerm %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ WezTerm shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "alacritty":
|
||||
modified, path := SetupAlacrittyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Alacritty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Alacritty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "kitty":
|
||||
modified, path := SetupKittyKeybindings()
|
||||
if modified {
|
||||
fmt.Printf("%s Kitty %s\n", renderer.Dim("Configured shift+enter for"), renderer.Dim("terminal"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
} else if path != "" {
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ Kitty shift+enter already configured"))
|
||||
fmt.Printf("%s %s\n", renderer.Dim(" →"), path)
|
||||
}
|
||||
|
||||
case "iterm2":
|
||||
fmt.Printf("%s\n", renderer.Dim("✓ iTerm2 shift+enter works by default (maps to alt+enter)"))
|
||||
|
||||
case "terminal.app":
|
||||
fmt.Printf("%s\n", renderer.Dim("⚠ Terminal.app requires manual configuration"))
|
||||
fmt.Printf("%s\n", renderer.Dim(" See: Terminal → Preferences → Profiles → Keyboard"))
|
||||
|
||||
case "unknown":
|
||||
fmt.Printf("%s\n", renderer.Dim("ℹ Terminal not detected - use alt+enter or ctrl+j for newlines"))
|
||||
}
|
||||
}
|
||||
|
||||
// getVSCodeConfigPath returns the platform-specific path to VS Code's User directory
|
||||
func getVSCodeConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "Code", "User"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(appData, "Code", "User"), nil
|
||||
default: // linux, freebsd, etc.
|
||||
return filepath.Join(home, ".config", "Code", "User"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// getCursorConfigPath returns the platform-specific path to Cursor's User directory
|
||||
func getCursorConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "Cursor", "User"), nil
|
||||
case "windows":
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
appData = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return filepath.Join(appData, "Cursor", "User"), nil
|
||||
default: // linux, freebsd, etc.
|
||||
return filepath.Join(home, ".config", "Cursor", "User"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// DetectTerminal identifies which terminal emulator is currently running
|
||||
func DetectTerminal() string {
|
||||
// Check TERM_PROGRAM (works for most terminals)
|
||||
termProgram := os.Getenv("TERM_PROGRAM")
|
||||
switch termProgram {
|
||||
case "vscode":
|
||||
return "vscode" // Also covers Cursor (uses same value)
|
||||
case "WezTerm":
|
||||
return "wezterm"
|
||||
case "ghostty":
|
||||
return "ghostty"
|
||||
case "iTerm.app":
|
||||
return "iterm2"
|
||||
case "Apple_Terminal":
|
||||
return "terminal.app"
|
||||
}
|
||||
|
||||
// Kitty doesn't set TERM_PROGRAM, check KITTY_WINDOW_ID
|
||||
if os.Getenv("KITTY_WINDOW_ID") != "" {
|
||||
return "kitty"
|
||||
}
|
||||
|
||||
// Alacritty doesn't set TERM_PROGRAM, check ALACRITTY_SOCKET
|
||||
if os.Getenv("ALACRITTY_SOCKET") != "" {
|
||||
return "alacritty"
|
||||
}
|
||||
|
||||
// Ghostty fallback (cross-platform - more reliable than TERM_PROGRAM)
|
||||
if os.Getenv("GHOSTTY_RESOURCES_DIR") != "" {
|
||||
return "ghostty"
|
||||
}
|
||||
|
||||
// Alacritty fallback
|
||||
if os.Getenv("ALACRITTY_LOG") != "" {
|
||||
return "alacritty"
|
||||
}
|
||||
|
||||
// Check TERM variable as last resort
|
||||
term := os.Getenv("TERM")
|
||||
if strings.Contains(term, "kitty") {
|
||||
return "kitty"
|
||||
}
|
||||
if term == "alacritty" {
|
||||
return "alacritty"
|
||||
}
|
||||
if term == "xterm-ghostty" {
|
||||
return "ghostty"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// VSCodeKeybinding represents a VS Code keyboard shortcut
|
||||
type VSCodeKeybinding struct {
|
||||
Key string `json:"key"`
|
||||
Command string `json:"command"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
When string `json:"when,omitempty"`
|
||||
}
|
||||
|
||||
// SetupVSCodeKeybindings adds shift+enter support to VS Code's integrated terminal
|
||||
// by modifying the user's keybindings.json file.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupVSCodeKeybindings() (bool, string) {
|
||||
// Get platform-specific VS Code config path
|
||||
configDir, err := getVSCodeConfigPath()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
keybindingsPath := filepath.Join(configDir, "keybindings.json")
|
||||
|
||||
// Check if VS Code is installed (keybindings file or parent dir exists)
|
||||
if _, err := os.Stat(filepath.Dir(keybindingsPath)); os.IsNotExist(err) {
|
||||
// VS Code not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing keybindings
|
||||
var keybindings []VSCodeKeybinding
|
||||
|
||||
data, err := os.ReadFile(keybindingsPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, ""
|
||||
}
|
||||
// File doesn't exist, start with empty array
|
||||
keybindings = []VSCodeKeybinding{}
|
||||
} else {
|
||||
// Parse existing keybindings
|
||||
if err := json.Unmarshal(data, &keybindings); err != nil {
|
||||
// If parse fails, don't modify the file
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shift+enter binding already exists
|
||||
for _, kb := range keybindings {
|
||||
if kb.Key == "shift+enter" && kb.Command == "workbench.action.terminal.sendSequence" {
|
||||
// Already configured
|
||||
return false, keybindingsPath
|
||||
}
|
||||
}
|
||||
|
||||
// Add shift+enter keybinding
|
||||
newBinding := VSCodeKeybinding{
|
||||
Key: "shift+enter",
|
||||
Command: "workbench.action.terminal.sendSequence",
|
||||
Args: map[string]interface{}{
|
||||
"text": "\u001b\n", // ESC + newline (alt+enter sequence)
|
||||
},
|
||||
When: "terminalFocus",
|
||||
}
|
||||
|
||||
keybindings = append(keybindings, newBinding)
|
||||
|
||||
// Create backup
|
||||
if data != nil {
|
||||
backupPath := keybindingsPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
}
|
||||
|
||||
// Write updated keybindings
|
||||
updatedData, err := json.MarshalIndent(keybindings, "", " ")
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(keybindingsPath), 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keybindingsPath, updatedData, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, keybindingsPath
|
||||
}
|
||||
|
||||
// SetupCursorKeybindings adds shift+enter support to Cursor's integrated terminal
|
||||
// by modifying the user's keybindings.json file.
|
||||
// Cursor is a fork of VS Code, so it uses the same keybinding format.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupCursorKeybindings() (bool, string) {
|
||||
// Get platform-specific Cursor config path
|
||||
configDir, err := getCursorConfigPath()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
keybindingsPath := filepath.Join(configDir, "keybindings.json")
|
||||
|
||||
// Check if Cursor is installed (keybindings file or parent dir exists)
|
||||
if _, err := os.Stat(filepath.Dir(keybindingsPath)); os.IsNotExist(err) {
|
||||
// Cursor not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing keybindings
|
||||
var keybindings []VSCodeKeybinding
|
||||
|
||||
data, err := os.ReadFile(keybindingsPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return false, ""
|
||||
}
|
||||
// File doesn't exist, start with empty array
|
||||
keybindings = []VSCodeKeybinding{}
|
||||
} else {
|
||||
// Parse existing keybindings
|
||||
if err := json.Unmarshal(data, &keybindings); err != nil {
|
||||
// If parse fails, don't modify the file
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shift+enter binding already exists
|
||||
for _, kb := range keybindings {
|
||||
if kb.Key == "shift+enter" && kb.Command == "workbench.action.terminal.sendSequence" {
|
||||
// Already configured
|
||||
return false, keybindingsPath
|
||||
}
|
||||
}
|
||||
|
||||
// Add shift+enter keybinding
|
||||
newBinding := VSCodeKeybinding{
|
||||
Key: "shift+enter",
|
||||
Command: "workbench.action.terminal.sendSequence",
|
||||
Args: map[string]interface{}{
|
||||
"text": "\u001b\n", // ESC + newline (alt+enter sequence)
|
||||
},
|
||||
When: "terminalFocus",
|
||||
}
|
||||
|
||||
keybindings = append(keybindings, newBinding)
|
||||
|
||||
// Create backup
|
||||
if data != nil {
|
||||
backupPath := keybindingsPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
}
|
||||
|
||||
// Write updated keybindings
|
||||
updatedData, err := json.MarshalIndent(keybindings, "", " ")
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(keybindingsPath), 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keybindingsPath, updatedData, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, keybindingsPath
|
||||
}
|
||||
|
||||
// SetupGhosttyKeybindings adds shift+enter support to Ghostty terminal
|
||||
// by appending to the user's config file.
|
||||
// Returns (wasModified, configPath) to allow caller to log the change.
|
||||
func SetupGhosttyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Ghostty config location: ~/.config/ghostty/config
|
||||
configPath := filepath.Join(home, ".config", "ghostty", "config")
|
||||
|
||||
// Check if config directory exists
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
// Ghostty not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config if it exists
|
||||
var existingContent []byte
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
existingContent = data
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "keybind = shift+enter") {
|
||||
return false, configPath
|
||||
}
|
||||
}
|
||||
|
||||
// Keybinding to add - send newline character (0x0a)
|
||||
// Ghostty requires \x0a hex escape syntax, verified working
|
||||
keybinding := "keybind = shift+enter=text:\\x0a\n"
|
||||
|
||||
// Append to config
|
||||
newContent := append(existingContent, []byte(keybinding)...)
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Create backup if file exists
|
||||
if existingContent != nil {
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, existingContent, 0644)
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupWezTermKeybindings adds shift+enter support to WezTerm
|
||||
// by appending to the user's .wezterm.lua file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupWezTermKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".wezterm.lua")
|
||||
|
||||
// Check if WezTerm config exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// WezTerm not configured, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "key = 'Enter'") && strings.Contains(string(data), "mods = 'SHIFT'") {
|
||||
return false, configPath
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
|
||||
// Keybinding to add (insert before final return statement)
|
||||
keybinding := `
|
||||
-- Shift+Enter for newlines (added by Cline CLI)
|
||||
config.keys = config.keys or {}
|
||||
table.insert(config.keys, {
|
||||
key = 'Enter',
|
||||
mods = 'SHIFT',
|
||||
action = wezterm.action.SendString '\x1b\n',
|
||||
})
|
||||
`
|
||||
|
||||
content := string(data)
|
||||
// Try to insert before the final return statement
|
||||
if strings.Contains(content, "return config") {
|
||||
content = strings.Replace(content, "return config", keybinding+"\nreturn config", 1)
|
||||
} else {
|
||||
// No return statement, append at end
|
||||
content += keybinding
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupAlacrittyKeybindings adds shift+enter support to Alacritty
|
||||
// by appending to the user's alacritty.yml file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupAlacrittyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Try both possible locations
|
||||
configPaths := []string{
|
||||
filepath.Join(home, ".config", "alacritty", "alacritty.yml"),
|
||||
filepath.Join(home, ".config", "alacritty", "alacritty.toml"),
|
||||
filepath.Join(home, ".alacritty.yml"),
|
||||
}
|
||||
|
||||
var configPath string
|
||||
for _, path := range configPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
configPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
// Alacritty not configured, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "key: Return") && strings.Contains(string(data), "mods: Shift") {
|
||||
return false, configPath
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, data, 0644)
|
||||
|
||||
// Keybinding to add
|
||||
var keybinding string
|
||||
if strings.HasSuffix(configPath, ".yml") || strings.HasSuffix(configPath, ".yaml") {
|
||||
keybinding = `
|
||||
# Shift+Enter for newlines (added by Cline CLI)
|
||||
key_bindings:
|
||||
- { key: Return, mods: Shift, chars: "\x1b\n" }
|
||||
`
|
||||
} else {
|
||||
// TOML format
|
||||
keybinding = `
|
||||
# Shift+Enter for newlines (added by Cline CLI)
|
||||
[[keyboard.bindings]]
|
||||
key = "Return"
|
||||
mods = "Shift"
|
||||
chars = "\x1b\n"
|
||||
`
|
||||
}
|
||||
|
||||
// Append to config
|
||||
newContent := append(data, []byte(keybinding)...)
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
|
||||
// SetupKittyKeybindings adds shift+enter support to Kitty terminal
|
||||
// by appending to the user's kitty.conf file.
|
||||
// Returns (wasModified, configPath)
|
||||
func SetupKittyKeybindings() (bool, string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".config", "kitty", "kitty.conf")
|
||||
|
||||
// Check if config directory exists
|
||||
configDir := filepath.Dir(configPath)
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
// Kitty not installed, skip silently
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Read existing config if it exists
|
||||
var existingContent []byte
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
existingContent = data
|
||||
// Check if shift+enter already configured
|
||||
if strings.Contains(string(data), "map shift+enter") {
|
||||
return false, configPath
|
||||
}
|
||||
}
|
||||
|
||||
// Keybinding to add
|
||||
keybinding := "# Shift+Enter for newlines (added by Cline CLI)\nmap shift+enter send_text all \\x1b\\n\n"
|
||||
|
||||
// Append to config
|
||||
newContent := append(existingContent, []byte(keybinding)...)
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Create backup if file exists
|
||||
if existingContent != nil {
|
||||
backupPath := configPath + ".backup"
|
||||
_ = os.WriteFile(backupPath, existingContent, 0644)
|
||||
}
|
||||
|
||||
// Write updated config
|
||||
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, configPath
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
if you can make a beautiful tui in go, please help!
|
||||
@@ -0,0 +1,17 @@
|
||||
package types
|
||||
|
||||
// HistoryItem represents a task history item from taskHistory.json
|
||||
// This struct matches the JSON format stored on disk
|
||||
type HistoryItem struct {
|
||||
Id string `json:"id"`
|
||||
Ulid string `json:"ulid,omitempty"`
|
||||
Ts int64 `json:"ts"`
|
||||
Task string `json:"task"`
|
||||
TokensIn int32 `json:"tokensIn"`
|
||||
TokensOut int32 `json:"tokensOut"`
|
||||
CacheWrites int32 `json:"cacheWrites,omitempty"`
|
||||
CacheReads int32 `json:"cacheReads,omitempty"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsFavorited bool `json:"isFavorited,omitempty"`
|
||||
}
|
||||
@@ -3,22 +3,26 @@ package types
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"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"`
|
||||
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
|
||||
@@ -65,6 +69,7 @@ const (
|
||||
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"
|
||||
@@ -206,13 +211,16 @@ func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +287,8 @@ func convertProtoSayType(sayType cline.ClineSay) string {
|
||||
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:
|
||||
|
||||
@@ -12,9 +12,8 @@ type ConversationState struct {
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
LastToolMessage string `json:"lastToolMessage,omitempty"`
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
@@ -37,9 +36,8 @@ func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
LastToolMessage: cs.StreamingMessage.LastToolMessage,
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
)
|
||||
|
||||
type cacheData struct {
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
}
|
||||
|
||||
type npmRegistryResponse struct {
|
||||
DistTags struct {
|
||||
Latest string `json:"latest"`
|
||||
Nightly string `json:"nightly"`
|
||||
} `json:"dist-tags"`
|
||||
}
|
||||
|
||||
const (
|
||||
checkInterval = 24 * time.Hour
|
||||
requestTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
)
|
||||
|
||||
var verbose bool
|
||||
|
||||
// CheckAndUpdate performs a background update check and attempts to auto-update if needed.
|
||||
// This is non-blocking and safe to call on CLI startup.
|
||||
func CheckAndUpdate(isVerbose bool) {
|
||||
verbose = isVerbose
|
||||
|
||||
// Skip in CI environments
|
||||
if os.Getenv("CI") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (CI environment)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if user disabled auto-updates
|
||||
if os.Getenv("NO_AUTO_UPDATE") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Starting background update check...\n")
|
||||
}
|
||||
|
||||
// Run in background so we don't block CLI startup
|
||||
go func() {
|
||||
if err := checkAndUpdateInternal(false); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// CheckAndUpdateSync performs a synchronous update check (blocks until complete).
|
||||
// If bypassCache is true, ignores the 24-hour cache and always checks npm registry.
|
||||
// This is used by the doctor command.
|
||||
func CheckAndUpdateSync(isVerbose bool, bypassCache bool) {
|
||||
verbose = isVerbose
|
||||
|
||||
// Skip in CI environments
|
||||
if os.Getenv("CI") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (CI environment)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if user disabled auto-updates
|
||||
if os.Getenv("NO_AUTO_UPDATE") != "" {
|
||||
if verbose {
|
||||
output.Printf("[updater] Skipping update check (NO_AUTO_UPDATE set)\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Starting update check...\n")
|
||||
}
|
||||
|
||||
// Run synchronously
|
||||
if err := checkAndUpdateInternal(bypassCache); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update check failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAndUpdateInternal(bypassCache bool) error {
|
||||
if verbose {
|
||||
output.Printf("[updater] Loading update cache...\n")
|
||||
}
|
||||
|
||||
// Load cache
|
||||
cache, err := loadCache()
|
||||
if !bypassCache && err == nil && time.Since(cache.LastCheck) < checkInterval {
|
||||
// Checked recently, skip (unless cache is bypassed)
|
||||
if verbose {
|
||||
output.Printf("[updater] Cache is fresh (last checked %v ago), skipping\n", time.Since(cache.LastCheck))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil && verbose {
|
||||
output.Printf("[updater] Cache load failed or doesn't exist: %v\n", err)
|
||||
}
|
||||
|
||||
// Determine channel
|
||||
distTag := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
distTag = "nightly"
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Current version: %s (channel: %s)\n", global.CliVersion, distTag)
|
||||
output.Printf("[updater] Fetching latest version from npm registry...\n")
|
||||
}
|
||||
|
||||
// Fetch latest version from npm
|
||||
latestVersion, err := fetchLatestVersion()
|
||||
if err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Failed to fetch latest version: %v\n", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Latest version on npm: %s\n", latestVersion)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cache = cacheData{
|
||||
LastCheck: time.Now(),
|
||||
LatestVersion: latestVersion,
|
||||
}
|
||||
saveCache(cache)
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Updated cache\n")
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
currentVersion := strings.TrimPrefix(global.CliVersion, "v")
|
||||
latestVersion = strings.TrimPrefix(latestVersion, "v")
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Comparing versions: current=%s latest=%s\n", currentVersion, latestVersion)
|
||||
}
|
||||
|
||||
if !isNewer(latestVersion, currentVersion) {
|
||||
// Already up to date
|
||||
if verbose {
|
||||
output.Printf("[updater] Already on latest version, no update needed\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Update available! Attempting to install...\n")
|
||||
}
|
||||
|
||||
// Determine channel for update command
|
||||
channel := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
channel = "nightly"
|
||||
}
|
||||
|
||||
// Attempt update
|
||||
if verbose {
|
||||
output.Printf("[updater] Running: npm install -g cline%s\n",
|
||||
map[bool]string{true: "@"+channel, false: ""}[channel == "nightly"])
|
||||
}
|
||||
|
||||
if err := attemptUpdate(channel); err != nil {
|
||||
if verbose {
|
||||
output.Printf("[updater] Update failed: %v\n", err)
|
||||
}
|
||||
showFailureMessage(channel)
|
||||
return err
|
||||
}
|
||||
|
||||
if verbose {
|
||||
output.Printf("[updater] Update completed successfully!\n")
|
||||
}
|
||||
|
||||
showSuccessMessage(latestVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
// Determine dist-tag from current version
|
||||
distTag := "latest"
|
||||
if strings.Contains(global.CliVersion, "nightly") {
|
||||
distTag = "nightly"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://registry.npmjs.org/cline", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("npm registry returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data npmRegistryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if distTag == "nightly" {
|
||||
return data.DistTags.Nightly, nil
|
||||
}
|
||||
return data.DistTags.Latest, nil
|
||||
}
|
||||
|
||||
func attemptUpdate(channel string) error {
|
||||
packageName := "cline"
|
||||
if channel == "nightly" {
|
||||
packageName = "cline@nightly"
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "install", "-g", packageName)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func isNewer(latest, current string) bool {
|
||||
// Parse version strings (e.g., "1.0.0-nightly.19")
|
||||
latestBase, latestSuffix := parseVersion(latest)
|
||||
currentBase, currentSuffix := parseVersion(current)
|
||||
|
||||
// Compare base versions (1.0.0)
|
||||
comparison := compareVersionParts(latestBase, currentBase)
|
||||
if comparison != 0 {
|
||||
return comparison > 0
|
||||
}
|
||||
|
||||
// Base versions are equal, compare suffixes (nightly.19)
|
||||
return compareSuffix(latestSuffix, currentSuffix) > 0
|
||||
}
|
||||
|
||||
func parseVersion(version string) (string, string) {
|
||||
parts := strings.SplitN(version, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return parts[0], ""
|
||||
}
|
||||
|
||||
func compareVersionParts(v1, v2 string) int {
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
for i := 0; i < len(parts1) && i < len(parts2); i++ {
|
||||
// Convert to int for proper numeric comparison
|
||||
n1 := parseInt(parts1[i])
|
||||
n2 := parseInt(parts2[i])
|
||||
|
||||
if n1 > n2 {
|
||||
return 1
|
||||
}
|
||||
if n1 < n2 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// If all parts are equal, longer version is newer
|
||||
if len(parts1) > len(parts2) {
|
||||
return 1
|
||||
}
|
||||
if len(parts1) < len(parts2) {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareSuffix(s1, s2 string) int {
|
||||
// If one has no suffix, stable > prerelease
|
||||
if s1 == "" && s2 == "" {
|
||||
return 0
|
||||
}
|
||||
if s1 == "" {
|
||||
return 1 // Stable is newer than prerelease
|
||||
}
|
||||
if s2 == "" {
|
||||
return -1 // Prerelease is older than stable
|
||||
}
|
||||
|
||||
// Both have suffixes (e.g., "nightly.19" vs "nightly.18")
|
||||
// Extract the numeric part after the last dot
|
||||
n1 := extractBuildNumber(s1)
|
||||
n2 := extractBuildNumber(s2)
|
||||
|
||||
if n1 > n2 {
|
||||
return 1
|
||||
}
|
||||
if n1 < n2 {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractBuildNumber(suffix string) int {
|
||||
// Extract number from "nightly.19" -> 19
|
||||
parts := strings.Split(suffix, ".")
|
||||
if len(parts) > 1 {
|
||||
return parseInt(parts[len(parts)-1])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var result int
|
||||
fmt.Sscanf(s, "%d", &result)
|
||||
return result
|
||||
}
|
||||
|
||||
func showSuccessMessage(version string) {
|
||||
output.Printf("\n%s Updated to %s %s Changes will take effect next session\n\n",
|
||||
successStyle.Render("✓"),
|
||||
successStyle.Render("v"+version),
|
||||
dimStyle.Render("→"),
|
||||
)
|
||||
}
|
||||
|
||||
func showFailureMessage(channel string) {
|
||||
packageName := "cline"
|
||||
if channel == "nightly" {
|
||||
packageName = "cline@nightly"
|
||||
}
|
||||
|
||||
output.Printf("\n%s Auto-update failed %s Try: %s\n\n",
|
||||
errorStyle.Render("✗"),
|
||||
dimStyle.Render("·"),
|
||||
"npm install -g "+packageName,
|
||||
)
|
||||
}
|
||||
|
||||
func getCacheFilePath() string {
|
||||
configDir := filepath.Join(os.Getenv("HOME"), ".cline", "data")
|
||||
return filepath.Join(configDir, ".update-cache")
|
||||
}
|
||||
|
||||
func loadCache() (cacheData, error) {
|
||||
var cache cacheData
|
||||
cacheFile := getCacheFilePath()
|
||||
|
||||
data, err := os.ReadFile(cacheFile)
|
||||
if err != nil {
|
||||
return cache, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &cache)
|
||||
return cache, err
|
||||
}
|
||||
|
||||
func saveCache(cache cacheData) error {
|
||||
cacheFile := getCacheFilePath()
|
||||
|
||||
// Ensure config directory exists
|
||||
configDir := filepath.Dir(cacheFile)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cache)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cacheFile, data, 0644)
|
||||
}
|
||||
+16
-20
@@ -4,38 +4,34 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"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",
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline Go host.`,
|
||||
Use: "version",
|
||||
Aliases: []string{"v"},
|
||||
Short: "Show version information",
|
||||
Long: `Display version information for the Cline CLI.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Versions are injected at build time via ldflags
|
||||
if short {
|
||||
fmt.Println(Version)
|
||||
fmt.Println(global.CliVersion)
|
||||
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)
|
||||
fmt.Printf("Cline CLI\n")
|
||||
fmt.Printf("Cline CLI Version: %s\n", global.CliVersion)
|
||||
fmt.Printf("Cline Core Version: %s\n", global.Version)
|
||||
fmt.Printf("Commit: %s\n", global.Commit)
|
||||
fmt.Printf("Built: %s\n", global.Date)
|
||||
fmt.Printf("Built by: %s\n", global.BuiltBy)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -44,4 +40,4 @@ func NewVersionCommand() *cobra.Command {
|
||||
cmd.Flags().BoolVar(&short, "short", false, "show only version number")
|
||||
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
@@ -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
+88
-20
@@ -3,9 +3,13 @@ package hostbridge
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
@@ -31,11 +35,17 @@ func NewEnvService(verbose bool) *EnvService {
|
||||
// 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: %s", req.GetValue())
|
||||
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)
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, just return success
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
@@ -45,23 +55,17 @@ func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequ
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMachineId returns a stable machine identifier for telemetry distinctId purposes
|
||||
func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetMachineId 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 = ""
|
||||
}
|
||||
|
||||
// TODO: Implement actual machine ID functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -71,9 +75,12 @@ func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual host version functionality
|
||||
// For now, return empty response
|
||||
return &host.GetHostVersionResponse{}, nil
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(global.CliVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
@@ -96,3 +103,64 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl
|
||||
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings for CLI mode
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
setting = host.Setting_ENABLED
|
||||
} else {
|
||||
setting = host.Setting_DISABLED
|
||||
}
|
||||
|
||||
return &host.GetTelemetrySettingsResponse{
|
||||
IsEnabled: setting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubscribeToTelemetrySettings returns a stream of telemetry setting changes
|
||||
// In CLI mode, telemetry settings don't change at runtime, so we just send
|
||||
// the current state and keep the stream open
|
||||
func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, stream host.EnvService_SubscribeToTelemetrySettingsServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// Send initial telemetry state
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
setting = host.Setting_ENABLED
|
||||
} else {
|
||||
setting = host.Setting_DISABLED
|
||||
}
|
||||
|
||||
event := &host.TelemetrySettingsEvent{
|
||||
IsEnabled: setting,
|
||||
}
|
||||
|
||||
if err := stream.Send(event); err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to send telemetry settings event: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep stream open until context is cancelled
|
||||
// (In CLI mode, settings don't change dynamically)
|
||||
<-stream.Context().Done()
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToTelemetrySettings stream closed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -63,3 +63,23 @@ func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.G
|
||||
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
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,403 @@
|
||||
---
|
||||
title: "CLI Reference"
|
||||
description: "Complete command reference for Cline CLI including configuration, instance management, and task commands"
|
||||
---
|
||||
|
||||
Complete command reference for Cline CLI. Use this for detailed documentation on all commands, options, and configuration.
|
||||
|
||||
For quick help in your terminal:
|
||||
|
||||
```bash
|
||||
cline --help # Show all commands
|
||||
cline task --help # Show task-specific commands
|
||||
man cline # View the full manual page
|
||||
```
|
||||
|
||||
## Manual Page
|
||||
|
||||
The complete manual page for the Cline CLI:
|
||||
|
||||
```
|
||||
CLINE(1) User Commands CLINE(1)
|
||||
|
||||
NAME
|
||||
cline - orchestrate and interact with Cline AI coding agents
|
||||
|
||||
SYNOPSIS
|
||||
cline [prompt] [options]
|
||||
|
||||
cline command [subcommand] [options] [arguments]
|
||||
|
||||
DESCRIPTION
|
||||
Try: cat README.md | cline "Summarize this for me:"
|
||||
|
||||
cline is a command-line interface for orchestrating multiple Cline AI
|
||||
coding agents. Cline is an autonomous AI agent who can read, write,
|
||||
and execute code across your projects. He operates through a
|
||||
client-server architecture where Cline Core runs as a standalone
|
||||
service, and the CLI acts as a scriptable interface for managing tasks,
|
||||
instances, and agent interactions.
|
||||
|
||||
The CLI is designed for both interactive use and automation, making it
|
||||
ideal for CI/CD pipelines, parallel task execution, and terminal-based
|
||||
workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to
|
||||
the same Cline Core instance, enabling seamless task handoff between
|
||||
environments.
|
||||
|
||||
MODES OF OPERATION
|
||||
Instant Task Mode
|
||||
The simplest invocation: cline "prompt here" immediately spawns
|
||||
an instance, creates a task, and enters chat mode. This is
|
||||
equivalent to running cline instance new && cline task new &&
|
||||
cline task chat in sequence.
|
||||
|
||||
Subcommand Mode
|
||||
Advanced usage with explicit control: cline <command>
|
||||
[subcommand] [options] provides fine-grained control over
|
||||
instances, tasks, authentication, and configuration.
|
||||
|
||||
AGENT BEHAVIOR
|
||||
Cline operates in two primary modes:
|
||||
|
||||
ACT MODE
|
||||
Cline actively uses tools to accomplish tasks. He can read
|
||||
files, write code, execute commands, use a headless browser, and
|
||||
more. This is the default mode for task execution.
|
||||
|
||||
PLAN MODE
|
||||
Cline gathers information and creates a detailed plan before
|
||||
implementation. He explores the codebase, asks clarifying
|
||||
questions, and presents a strategy for user approval before
|
||||
switching to ACT MODE.
|
||||
|
||||
INSTANT TASK OPTIONS
|
||||
When using the instant task syntax cline "prompt" the following options
|
||||
are available:
|
||||
|
||||
-o, --oneshot
|
||||
Full autonomous mode. Cline completes the task and stops
|
||||
following after completion. Example: cline -o "what's 6 + 8?"
|
||||
|
||||
-s, --setting setting value
|
||||
Override a setting for this task
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable fully autonomous mode. Disables all interactivity:
|
||||
|
||||
• ask_followup_question tool is disabled
|
||||
|
||||
• attempt_completion happens automatically
|
||||
|
||||
• execute_command runs in non-blocking mode with timeout
|
||||
|
||||
• PLAN MODE automatically switches to ACT MODE
|
||||
|
||||
-m, --mode mode
|
||||
Starting mode. Options: act (default), plan
|
||||
|
||||
GLOBAL OPTIONS
|
||||
These options apply to all subcommands:
|
||||
|
||||
-F, --output-format format
|
||||
Output format. Options: rich (default), json, plain
|
||||
|
||||
-h, --help
|
||||
Display help information for the command.
|
||||
|
||||
-v, --verbose
|
||||
Enable verbose output for debugging.
|
||||
|
||||
COMMANDS
|
||||
Authentication
|
||||
cline auth [provider] [key]
|
||||
|
||||
cline a [provider] [key]
|
||||
Configure authentication for AI model providers. Launches an
|
||||
interactive wizard if no arguments provided. If provider is
|
||||
specified without a key, prompts for the key or launches the
|
||||
appropriate OAuth flow.
|
||||
|
||||
Instance Management
|
||||
Cline Core instances are independent agent processes that can run in
|
||||
the background. Multiple instances can run simultaneously, enabling
|
||||
parallel task execution.
|
||||
|
||||
cline instance
|
||||
|
||||
cline i
|
||||
Display instance management help.
|
||||
|
||||
cline instance new [-d|--default]
|
||||
|
||||
cline i n [-d|--default]
|
||||
Spawn a new Cline Core instance. Use --default to set it as
|
||||
the default instance for subsequent commands.
|
||||
|
||||
cline instance list
|
||||
|
||||
cline i l
|
||||
List all running Cline Core instances with their addresses and
|
||||
status.
|
||||
|
||||
cline instance default address
|
||||
|
||||
cline i d address
|
||||
Set the default instance to avoid specifying --address in task
|
||||
commands.
|
||||
|
||||
cline instance kill address [-a|--all]
|
||||
|
||||
cline i k address [-a|--all]
|
||||
Terminate a Cline Core instance. Use --all to kill all running
|
||||
instances.
|
||||
|
||||
Task Management
|
||||
Tasks represent individual work items that Cline executes. Tasks
|
||||
maintain conversation history, checkpoints, and settings.
|
||||
|
||||
cline task [-a|--address ADDR]
|
||||
|
||||
cline t [-a|--address ADDR]
|
||||
Display task management help. The --address flag specifies
|
||||
which Cline Core instance to use (e.g., localhost:50052).
|
||||
|
||||
cline task new prompt [options]
|
||||
|
||||
cline t n prompt [options]
|
||||
Create a new task in the default or specified instance.
|
||||
Options:
|
||||
|
||||
-s, --setting setting value
|
||||
Set task-specific settings
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable autonomous mode
|
||||
|
||||
-m, --mode mode
|
||||
Starting mode (act or plan)
|
||||
|
||||
cline task open task-id [options]
|
||||
|
||||
cline t o task-id [options]
|
||||
Resume a previous task from history. Accepts the same options
|
||||
as task new.
|
||||
|
||||
cline task list
|
||||
|
||||
cline t l
|
||||
List all tasks in history with their id and snippet
|
||||
|
||||
cline task chat
|
||||
|
||||
cline t c
|
||||
Enter interactive chat mode for the current task. Allows
|
||||
back-and-forth conversation with Cline.
|
||||
|
||||
cline task send [message] [options]
|
||||
|
||||
cline t s [message] [options]
|
||||
Send a message to Cline. If no message is provided, reads from
|
||||
stdin. Options:
|
||||
|
||||
-a, --approve
|
||||
Approve Cline's proposed action
|
||||
|
||||
-d, --deny
|
||||
Deny Cline's proposed action
|
||||
|
||||
-f, --file FILE
|
||||
Attach a file to the message
|
||||
|
||||
-y, --no-interactive, --yolo
|
||||
Enable autonomous mode
|
||||
|
||||
-m, --mode mode
|
||||
Switch mode (act or plan)
|
||||
|
||||
cline task view [-f|--follow] [-c|--follow-complete]
|
||||
|
||||
cline t v [-f|--follow] [-c|--follow-complete]
|
||||
Display the current conversation. Use --follow to stream
|
||||
updates in real-time, or --follow-complete to follow until task
|
||||
completion.
|
||||
|
||||
cline task restore checkpoint
|
||||
|
||||
cline t r checkpoint
|
||||
Restore the task to a previous checkpoint state.
|
||||
|
||||
cline task pause
|
||||
|
||||
cline t p
|
||||
Pause task execution.
|
||||
|
||||
Configuration
|
||||
Configuration can be set globally. Override these global settings for
|
||||
a task using the --setting flag
|
||||
|
||||
cline config
|
||||
|
||||
cline c
|
||||
|
||||
cline config set key value
|
||||
|
||||
cline c s key value
|
||||
Set a configuration variable.
|
||||
|
||||
cline config get key
|
||||
|
||||
cline c g key
|
||||
Read a configuration variable.
|
||||
|
||||
cline config list
|
||||
|
||||
cline c l
|
||||
List all configuration variables and their values.
|
||||
|
||||
TASK SETTINGS
|
||||
Task settings are persisted in the ~/.cline/x/tasks directory. When
|
||||
resuming a task with cline task open, task settings are automatically
|
||||
restored.
|
||||
|
||||
Common settings include:
|
||||
|
||||
yolo Enable autonomous mode (true/false)
|
||||
|
||||
mode Starting mode (act/plan)
|
||||
|
||||
NOTES & EXAMPLES
|
||||
The cline task send and cline task new commands support reading from
|
||||
stdin, enabling powerful pipeline compositions:
|
||||
|
||||
cat requirements.txt | cline task send
|
||||
echo "Refactor this code" | cline -y
|
||||
|
||||
Instance Management
|
||||
Manage multiple Cline instances:
|
||||
|
||||
# Start a new instance and make it default
|
||||
cline instance new --default
|
||||
|
||||
# List all running instances
|
||||
cline instance list
|
||||
|
||||
# Kill a specific instance
|
||||
cline instance kill localhost:50052
|
||||
|
||||
# Kill all CLI instances
|
||||
cline instance kill --all-cli
|
||||
|
||||
Task History
|
||||
Work with task history:
|
||||
|
||||
# List previous tasks
|
||||
cline task list
|
||||
|
||||
# Resume a previous task
|
||||
cline task open 1760501486669
|
||||
|
||||
# View conversation history
|
||||
cline task view
|
||||
|
||||
# Start interactive chat with this task
|
||||
cline task chat
|
||||
|
||||
ARCHITECTURE
|
||||
Cline operates on a three-layer architecture:
|
||||
|
||||
Presentation Layer
|
||||
User interfaces (CLI, VSCode, JetBrains) that connect to Cline
|
||||
Core via gRPC
|
||||
|
||||
Cline Core
|
||||
The autonomous agent service handling task management, AI model
|
||||
integration, state management, tool orchestration, and real-time
|
||||
streaming updates
|
||||
|
||||
Host Provider Layer
|
||||
Environment-specific integrations (VSCode APIs, JetBrains APIs,
|
||||
shell APIs) that Cline Core uses to interact with the host
|
||||
system
|
||||
|
||||
BUGS
|
||||
Report bugs at: <https://github.com/cline/cline/issues>
|
||||
|
||||
For real-time help, join the Discord community at:
|
||||
<https://discord.gg/cline>
|
||||
|
||||
SEE ALSO
|
||||
Full documentation: <https://docs.cline.bot>
|
||||
|
||||
AUTHORS
|
||||
Cline is developed by the Cline Bot Inc. and the open source community.
|
||||
|
||||
COPYRIGHT
|
||||
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
|
||||
```
|
||||
|
||||
### Shell Completion
|
||||
|
||||
Generate autocompletion scripts for various shells:
|
||||
|
||||
#### Bash
|
||||
|
||||
```bash
|
||||
# Generate bash completion
|
||||
cline completion bash > /etc/bash_completion.d/cline
|
||||
|
||||
# Or for user-level installation
|
||||
cline completion bash > ~/.local/share/bash-completion/completions/cline
|
||||
```
|
||||
|
||||
#### Zsh
|
||||
|
||||
```bash
|
||||
# Generate zsh completion
|
||||
cline completion zsh > "${fpath[1]}/_cline"
|
||||
|
||||
# Or add to your .zshrc
|
||||
echo 'source <(cline completion zsh)' >> ~/.zshrc
|
||||
```
|
||||
|
||||
#### Fish
|
||||
|
||||
```bash
|
||||
# Generate fish completion
|
||||
cline completion fish > ~/.config/fish/completions/cline.fish
|
||||
```
|
||||
|
||||
#### PowerShell
|
||||
|
||||
```powershell
|
||||
# Generate PowerShell completion
|
||||
cline completion powershell > cline.ps1
|
||||
|
||||
# Add to your PowerShell profile
|
||||
Add-Content $PROFILE "cline completion powershell | Out-String | Invoke-Expression"
|
||||
```
|
||||
|
||||
### Version Command
|
||||
|
||||
```bash
|
||||
# Show version information
|
||||
cline version
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### CLINE_DIR
|
||||
|
||||
Override the default Cline directory location:
|
||||
|
||||
```bash
|
||||
# Override default Cline directory
|
||||
export CLINE_DIR=/custom/path
|
||||
|
||||
# Default: ~/.cline
|
||||
```
|
||||
|
||||
This directory is used for:
|
||||
- Instance registry database
|
||||
- Configuration files
|
||||
- Task history
|
||||
- Checkpoints
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Installation"
|
||||
description: "Install Cline CLI and authenticate with your account"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Cline CLI requires Node.js version 20 or higher. We recommend using Node.js 22 for the best experience.
|
||||
|
||||
To check your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g cline
|
||||
```
|
||||
|
||||
After installation, authenticate with your Cline account:
|
||||
|
||||
```bash
|
||||
cline auth
|
||||
```
|
||||
|
||||
This starts an authentication wizard to sign you in and configure your preferred AI model provider.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get started with Cline in seconds:
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
That's it! Running `cline` in any directory starts an interactive session where you can chat with the AI agent. Type your task, review the plan, and type `/act` when ready to execute.
|
||||
|
||||
For even faster execution without interaction:
|
||||
|
||||
```bash
|
||||
cline "Add unit tests to utils.js"
|
||||
```
|
||||
|
||||
This runs Cline with a single command, perfect for quick tasks or automation.
|
||||
|
||||
<Tip>
|
||||
New to Cline CLI? Start with interactive mode (`cline`) to see how it works. Once comfortable, explore [the three core flows](/cline-cli/three-core-flows) for advanced usage patterns.
|
||||
</Tip>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Overview"
|
||||
description: "Install the CLI, run your first task, and learn to automate code reviews and integrate AI agents into your development workflow"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**Preview Release - macOS and Linux Only**
|
||||
|
||||
Cline CLI is currently in preview and only available for macOS and Linux users. Windows support is coming soon.
|
||||
</Warning>
|
||||
|
||||
## What is Cline CLI?
|
||||
|
||||
Cline CLI runs AI coding agents directly in your terminal. Pipe git diffs for automated code reviews in CI/CD, run multiple instances simultaneously for parallel development, or integrate Cline into your existing shell workflows.
|
||||
|
||||
The CLI tracks instances across your system and outputs in formats designed for both humans and scripts—JSON, plain text, or rich terminal output.
|
||||
|
||||
<Tip>
|
||||
Ready to get started? Check out the [installation guide](/cline-cli/installation) to install Cline CLI and run your first task.
|
||||
</Tip>
|
||||
|
||||
## Supported Model Providers
|
||||
|
||||
Cline CLI supports multiple AI model providers, giving you flexibility in choosing the best model for your needs:
|
||||
|
||||
- **Anthropic**
|
||||
- **OpenAI**
|
||||
- **OpenAI Compatible**
|
||||
- **OpenRouter**
|
||||
- **X AI (Grok)**
|
||||
- **AWS Bedrock**
|
||||
- **Google Gemini**
|
||||
- **Ollama**
|
||||
- **Cerebras**
|
||||
|
||||
During installation, you'll authenticate and configure your preferred provider using the `cline auth` command.
|
||||
|
||||
## What you can build with this
|
||||
|
||||
**Automated code maintenance**
|
||||
- Schedule daily runs to identify and fix linting issues across your codebase
|
||||
- Create tasks that scan for security vulnerabilities and automatically patch them
|
||||
- Build scripts that update deprecated dependencies and run tests
|
||||
|
||||
**Multi-instance development**
|
||||
- Run separate Cline instances for frontend and backend simultaneously
|
||||
- Spawn instances for different feature branches, each with isolated state
|
||||
- Create parallel review processes for multiple PRs
|
||||
|
||||
**Custom workflows**
|
||||
- Build shell scripts that combine Cline with git hooks for pre-commit analysis
|
||||
- Create custom commands that pipe complex data structures through Cline for processing
|
||||
- Integrate with your existing toolchain (jq, grep, awk) for sophisticated automation
|
||||
|
||||
**CI/CD integration**
|
||||
- Add Cline to GitHub Actions for automatic code review on every PR
|
||||
- Create GitLab pipelines that generate migration scripts from schema changes
|
||||
- Build Jenkins jobs that use Cline to analyze test failures and suggest fixes
|
||||
|
||||
## Learn more
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Installation" icon="download" href="/cline-cli/installation">
|
||||
Install Cline CLI and authenticate with your account to get started.
|
||||
</Card>
|
||||
|
||||
<Card title="Three Core Flows" icon="route" href="/cline-cli/three-core-flows">
|
||||
Master the three ways to use Cline CLI: interactive mode, headless automation, and multi-instance parallelization.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: "Three Core Flows"
|
||||
description: "Learn the three ways to use Cline CLI: interactive mode, headless automation, and multi-instance parallelization"
|
||||
---
|
||||
|
||||
Two concepts to understand:
|
||||
|
||||
**Task** - A single job for Cline to complete ("add tests to utils.js"). You describe what you want, Cline plans how to do it, then executes the plan. Tasks run on instances.
|
||||
|
||||
**Instance** - An independent Cline workspace. Each instance runs one task at a time. Create multiple instances to run multiple tasks that work on different parts of your project in parallel.
|
||||
|
||||
## 1. Interactive mode: Plan first, then act
|
||||
|
||||
Start here to see how Cline works. Interactive mode opens a chat session where you can review plans before execution.
|
||||
|
||||
```bash
|
||||
cline
|
||||
```
|
||||
|
||||
Cline opens an interactive session in your current directory. Type your task as a message. Cline enters Plan mode and proposes a step-by-step strategy.
|
||||
|
||||
Review or edit the plan in chat. When you're ready, switch to execution:
|
||||
|
||||
```bash
|
||||
/act
|
||||
```
|
||||
|
||||
Cline executes the approved steps—reading files, writing code, running commands. You maintain control throughout the process.
|
||||
|
||||
## 2. Headless single-shot: Complete a task without chat
|
||||
|
||||
Use this for automation where you want a one-liner that just does the work.
|
||||
|
||||
```bash
|
||||
cline instance new --default
|
||||
cline task new -y "Generate unit tests for all Go files"
|
||||
```
|
||||
|
||||
With the `-y` (YOLO) flag, Cline plans and executes autonomously without interactive chat. Perfect for CI, cron jobs, or scripts.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Create a complete feature
|
||||
cline task new -y "Create a REST API for user authentication"
|
||||
|
||||
# Generate documentation
|
||||
cline task new -y "Add JSDoc comments to all functions in src/"
|
||||
|
||||
# Refactor code
|
||||
cline task new -y "Convert all var declarations to const/let"
|
||||
```
|
||||
|
||||
Monitor your task with:
|
||||
|
||||
```bash
|
||||
# View task status
|
||||
cline task view
|
||||
|
||||
# Follow task progress in real-time
|
||||
cline task view --follow
|
||||
```
|
||||
|
||||
Press Ctrl+C to exit the view.
|
||||
|
||||
<Note>
|
||||
Run YOLO mode with care on a directory or a clean Git branch. You get speed in exchange for oversight, so be ready to revert if needed.
|
||||
</Note>
|
||||
|
||||
## 3. Multi-instance: Run parallel agents
|
||||
|
||||
Multiple instances let you parallelize work on the same project without colliding contexts. Run frontend, backend, and infrastructure tasks simultaneously.
|
||||
|
||||
Create your first instance:
|
||||
|
||||
```bash
|
||||
cline instance new
|
||||
```
|
||||
|
||||
This returns an instance address you'll use to target tasks. Attach a task to this instance:
|
||||
|
||||
```bash
|
||||
# Frontend work on first instance
|
||||
cline task new -y "Build React components"
|
||||
```
|
||||
|
||||
Create a second instance and set it as default in one command:
|
||||
|
||||
```bash
|
||||
cline instance new --default
|
||||
```
|
||||
|
||||
Now you can create tasks without specifying the address—they automatically use the default instance:
|
||||
|
||||
```bash
|
||||
# Backend work on the new default instance
|
||||
cline task new -y "Implement API endpoints"
|
||||
```
|
||||
|
||||
List all running instances:
|
||||
|
||||
```bash
|
||||
cline instances list
|
||||
```
|
||||
|
||||
Stop all instances when done:
|
||||
|
||||
```bash
|
||||
cline instances kill -a
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Keep track of instance addresses returned by `cline instance new`. When scripting multiple agents, store these IDs and direct your tasks to the appropriate instance.
|
||||
</Tip>
|
||||
|
||||
## Choosing the right flow
|
||||
|
||||
- **Interactive mode**: Best for exploring new problems, learning how Cline works, or when you want to review plans before execution
|
||||
- **Headless single-shot**: Perfect for automation, CI/CD, and tasks where you trust Cline to execute without supervision
|
||||
- **Multi-instance**: Use when you need to parallelize work or maintain separate contexts for different parts of your project
|
||||
|
||||
<Tip>
|
||||
For in-depth commands and flags, check out the [CLI reference](/cline-cli/cli-reference) page for complete documentation on all available options.
|
||||
</Tip>
|
||||
|
||||
## Next steps
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="CLI reference" icon="terminal" href="/cline-cli/cli-reference">
|
||||
Complete command documentation including configuration, instance management, and task commands.
|
||||
</Card>
|
||||
|
||||
<Card title="Plan and Act" icon="brain" href="/features/plan-and-act">
|
||||
Deep dive into Plan and Act modes, including when to use each and how to switch between them.
|
||||
</Card>
|
||||
|
||||
<Card title="YOLO mode" icon="zap" href="/features/yolo-mode">
|
||||
Understand how YOLO mode works and when to use full automation versus manual approval.
|
||||
</Card>
|
||||
|
||||
<Card title="Task management" icon="clipboard-check" href="/getting-started/task-management">
|
||||
Learn how Cline tracks and manages tasks, including saving and restoring state from checkpoints.
|
||||
</Card>
|
||||
</Columns>
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: "Model Selection Guide"
|
||||
description: "Last updated: August 20, 2025."
|
||||
---
|
||||
|
||||
New models drop constantly, so this guide focuses on what's working well with Cline right now. We'll keep it updated as the landscape shifts.
|
||||
|
||||
<Callout type="tip">
|
||||
**New to model selection?** Start with [Module 2 of Cline's Learning Path](https://cline.bot/learn) for a comprehensive guide to choosing and configuring models.
|
||||
</Callout>
|
||||
|
||||
## What is an AI Model?
|
||||
|
||||
Think of an AI model as the "brain" that powers Cline. When you ask Cline to write code, fix bugs, or refactor your project, it's the model that actually understands your request and generates the response.
|
||||
|
||||
**Key points:**
|
||||
- **Models are trained AI systems** that understand natural language and code
|
||||
- **Different models have different strengths** some excel at complex reasoning, others prioritize speed or cost
|
||||
- **You choose which model Cline uses** like picking between different experts for different tasks
|
||||
- **Models are accessed via API providers** - companies like Anthropic, OpenAI, and OpenRouter host these models
|
||||
|
||||
**Why it matters:** The model you choose directly impacts Cline's capabilities, response quality, speed, and cost. A premium model might handle complex refactoring beautifully but cost more, while a budget model works great for routine tasks at a fraction of the price.
|
||||
|
||||
## How to Select a Model in Cline
|
||||
|
||||
Follow these 5 simple steps to get Cline up and running with your preferred AI model:
|
||||
|
||||
### Step 1: Open Cline Settings
|
||||
|
||||
First, you need to access Cline's configuration panel.
|
||||
|
||||
**Two ways to open settings:**
|
||||
- **Quick method**: Click the **gear icon (⚙️)** in the top-right corner of Cline's chat interface
|
||||
- **Command palette**: Press **Cmd/Ctrl + Shift + P** → type "Cline: Open Settings"
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/step1-config.png" alt="Cline Settings Panel" />
|
||||
</Frame>
|
||||
|
||||
The settings panel will open, showing configuration options with "API Provider" at the top.
|
||||
|
||||
<Note>
|
||||
The settings panel remembers your last configuration, so you'll only need to set this up once.
|
||||
</Note>
|
||||
|
||||
### Step 2: Select an API Provider
|
||||
|
||||
Choose your preferred AI provider from the dropdown menu.
|
||||
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/step2-provider.png" alt="Cline Settings Panel" />
|
||||
</Frame>
|
||||
|
||||
**Popular providers at a glance:**
|
||||
|
||||
| Provider | Best For | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Cline** | Easiest setup | No API keys needed, access to multiple models including stealth models |
|
||||
| **OpenRouter** | Value seekers | Multiple models, competitive pricing |
|
||||
| **Anthropic** | Reliability | Claude models, most dependable tool usage |
|
||||
| **OpenAI** | Latest tech | GPT models |
|
||||
| **Google Gemini** | Large context | Google's AI models |
|
||||
| **AWS Bedrock** | Enterprise | Advanced features |
|
||||
| **Ollama** | Privacy | Run models locally |
|
||||
|
||||
See the [full provider list](/provider-config) for more options including Cerebras, Vertex AI, Azure, and more.
|
||||
|
||||
<Info>
|
||||
**Recommended for beginners:** Start with **Cline** as your provider - no API key management needed, instant access to multiple models, and occasional free inferencing through partner providers.
|
||||
</Info>
|
||||
|
||||
### Step 3: Add Your API Key (or Sign In)
|
||||
|
||||
The next step depends on which provider you selected.
|
||||
|
||||
#### If you selected **Cline** as your provider:
|
||||
|
||||
- **No API key needed!** Simply sign in with your Cline account
|
||||
- Click the **Sign In** button when prompted
|
||||
- You'll be redirected to [app.cline.bot](https://app.cline.bot) to authenticate
|
||||
- After signing in, return to your IDE
|
||||
|
||||
#### If you selected any other provider:
|
||||
|
||||
You'll need to get an API key from your chosen provider:
|
||||
|
||||
1. **Visit your provider's website to get an API key:**
|
||||
- **Anthropic**: [console.anthropic.com](https://console.anthropic.com/)
|
||||
- **OpenRouter**: [openrouter.ai/keys](https://openrouter.ai/keys)
|
||||
- **OpenAI**: [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
|
||||
- **Google**: [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
|
||||
- **Others**: See [Provider Setup Guide](/provider-config)
|
||||
|
||||
2. **Generate a new API key** on the provider's website
|
||||
|
||||
3. **Copy the API key** to your clipboard
|
||||
|
||||
4. **Paste your key** in the **"API Key"** field in Cline settings
|
||||
|
||||
5. **Save automatically** - Your key is stored securely in your editor's secrets storage
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/step3-API.png" alt="Cline API Selection" />
|
||||
</Frame>
|
||||
|
||||
<Warning>
|
||||
**Payment required for most providers**: Most providers need payment information before generating keys. You only pay for what you use (typically $0.01-$0.10 per coding task).
|
||||
</Warning>
|
||||
|
||||
### Step 4: Choose Your Model
|
||||
|
||||
Once your API key is added (or you've signed in), the **"Model"** dropdown becomes available.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/step4-model.png" alt="Cline Model Selection" />
|
||||
</Frame>
|
||||
|
||||
**Quick model selection guide:**
|
||||
|
||||
| Your Priority | Choose This Model | Why |
|
||||
|---------------|-------------------|-----|
|
||||
| **Maximum reliability** | Claude Sonnet 4.5 | Most reliable tool usage, excellent at complex tasks |
|
||||
| **Best value** | DeepSeek V3 or Qwen3 Coder | Great performance at budget prices |
|
||||
| **Fastest speed** | Qwen3 Coder on Cerebras | Lightning-fast responses |
|
||||
| **Run locally** | Any Ollama model | Complete privacy, no internet needed |
|
||||
| **Latest features** | GPT-5 | OpenAI's newest capabilities |
|
||||
|
||||
Not sure which to pick? Start with **Claude Sonnet 4.5** for reliability or **DeepSeek V3** for value.
|
||||
|
||||
<Tip>
|
||||
You can switch models at any time without losing your conversation. Try different models to find what works best for your specific tasks.
|
||||
</Tip>
|
||||
|
||||
See the [model comparison tables](#current-top-models) below for detailed specifications and pricing.
|
||||
|
||||
### Step 5: Start Using Cline
|
||||
|
||||
**Congratulations! You're all set up.** Here's how to start coding with Cline:
|
||||
|
||||
1. **Type your request** in the Cline chat box
|
||||
- Example: "Create a React component for a login form"
|
||||
- Example: "Debug this TypeScript error"
|
||||
- Example: "Refactor this function to be more efficient"
|
||||
|
||||
2. **Press Enter** or click the send icon to submit
|
||||
|
||||
## Choosing the Right Model
|
||||
|
||||
Selecting the right model involves balancing several factors. Use this framework to find your ideal match:
|
||||
|
||||
<Note>
|
||||
**Pro tips**: Configure separate models for Plan Mode and Act Mode. Make the most out the each model's strengths. For example, use a budget model for planning discussions and a premium model for implementation.
|
||||
</Note>
|
||||
|
||||
### Key Selection Factors
|
||||
|
||||
| Factor | What to Consider | Recommendation |
|
||||
|--------|------------------|----------------|
|
||||
| **Task Complexity** | Simple fixes vs complex refactoring | Budget models for routine tasks; Premium models for complex work |
|
||||
| **Budget** | Monthly spending capacity | \$10-\$30: Budget, \$30-\$100: Mid-tier, \$100+: Premium |
|
||||
| **Context Window** | Project size and file count | Small: 32K-128K, Medium: 128K-200K, Large: 400K+ |
|
||||
| **Speed** | Response time requirements | Interactive: Fast models, Background: Reasoning models OK |
|
||||
| **Tool Reliability** | Complex operations | Claude excels at tool usage; Test others with your workflow |
|
||||
| **Provider** | Access and pricing needs | OpenRouter: Many options, Direct: Faster/reliable, Local: Privacy |
|
||||
|
||||
|
||||
|
||||
## Model Comparison Resources
|
||||
|
||||
For detailed model comparisons, pricing, and performance metrics, see:
|
||||
- [**Model Comparison & Pricing**](/model-config/model-comparison) - Complete pricing tables and performance benchmarks
|
||||
- [**Context Window Guide**](/model-config/context-windows) - Understanding and optimizing context usage
|
||||
|
||||
## Open Source vs Closed Source
|
||||
|
||||
### Open Source Advantages
|
||||
- **Multiple providers** compete to host them
|
||||
- **Cheaper pricing** due to competition
|
||||
- **Provider choice** - switch if one goes down
|
||||
- **Faster innovation** cycles
|
||||
|
||||
### Open Source Models Available
|
||||
- **Qwen3 Coder** (Apache 2.0)
|
||||
- **Z AI GLM 4.5** (MIT)
|
||||
- **Kimi K2** (Open source)
|
||||
- **DeepSeek series** (Various licenses)
|
||||
|
||||
## Quick Decision Matrix
|
||||
|
||||
| If you want... | Use this |
|
||||
|----------------|----------|
|
||||
| Something that just works | Claude Sonnet 4.5 |
|
||||
| To save money | DeepSeek V3 or Qwen3 variants |
|
||||
| Huge context windows | Gemini 2.5 Pro or Claude Sonnet 4.5 |
|
||||
| Open source | Qwen3 Coder, Z AI GLM 4.5, or Kimi K2 |
|
||||
| Latest tech | GPT-5 |
|
||||
| Speed | Qwen3 Coder on Cerebras (fastest available) |
|
||||
|
||||
## What Others Are Using
|
||||
|
||||
Check [OpenRouter's Cline usage stats](https://openrouter.ai/apps?url=https%3A%2F%2Fcline.bot%2F) to see real usage patterns from the community.
|
||||
+241
-167
@@ -2,15 +2,15 @@
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "linden",
|
||||
"name": "Cline",
|
||||
"description": "AI-powered coding assistant for VSCode",
|
||||
"description": "AI-powered coding agent for complex work",
|
||||
"colors": {
|
||||
"primary": "#9D4EDD",
|
||||
"light": "#F0E6FF",
|
||||
"dark": "#000000"
|
||||
},
|
||||
"logo": {
|
||||
"light": "/assets/robot_panel_light.png",
|
||||
"dark": "/assets/robot_panel_dark.png"
|
||||
"light": "/assets/Cline_Logo-complete_black.png",
|
||||
"dark": "/assets/Cline_Logo-complete_white.png"
|
||||
},
|
||||
"favicon": {
|
||||
"light": "/assets/robot_panel_light.png",
|
||||
@@ -18,10 +18,9 @@
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#F0E6FF",
|
||||
"dark": "#000000"
|
||||
},
|
||||
"decoration": "gradient"
|
||||
"light": "#fafaf9",
|
||||
"dark": "#0f0f0f"
|
||||
}
|
||||
},
|
||||
"styling": {
|
||||
"eyebrows": "breadcrumbs",
|
||||
@@ -33,16 +32,18 @@
|
||||
"strict": false
|
||||
},
|
||||
"fonts": {
|
||||
"family": "Roboto"
|
||||
"family": "Geist Sans"
|
||||
},
|
||||
"navbar": {
|
||||
"links": [
|
||||
{
|
||||
"label": "GitHub",
|
||||
"icon": "github",
|
||||
"href": "https://github.com/cline/cline"
|
||||
},
|
||||
{
|
||||
"label": "Discord",
|
||||
"icon": "discord",
|
||||
"href": "https://discord.gg/cline"
|
||||
}
|
||||
],
|
||||
@@ -53,168 +54,210 @@
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"groups": [
|
||||
"tabs": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"getting-started/what-is-cline",
|
||||
"getting-started/installing-cline",
|
||||
"getting-started/model-selection-guide",
|
||||
"getting-started/task-management",
|
||||
"getting-started/understanding-context-management",
|
||||
"tab": "Docs",
|
||||
"icon": "square-terminal",
|
||||
"groups": [
|
||||
{
|
||||
"group": "For New Coders",
|
||||
"group": "Introduction",
|
||||
"pages": [
|
||||
"getting-started/for-new-coders",
|
||||
"getting-started/installing-dev-essentials"
|
||||
"introduction/welcome",
|
||||
"introduction/overview"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"getting-started/installing-cline",
|
||||
"getting-started/selecting-your-model",
|
||||
"getting-started/your-first-project"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Best Practices",
|
||||
"pages": [
|
||||
"prompting/understanding-context-management",
|
||||
"prompting/prompt-engineering-guide",
|
||||
"prompting/cline-memory-bank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "CLI",
|
||||
"pages": [
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-cli/three-core-flows",
|
||||
"cline-cli/cli-reference"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Features",
|
||||
"pages": [
|
||||
{
|
||||
"group": "@ Mentions",
|
||||
"pages": [
|
||||
"features/at-mentions/overview",
|
||||
"features/at-mentions/file-mentions",
|
||||
"features/at-mentions/terminal-mentions",
|
||||
"features/at-mentions/problem-mentions",
|
||||
"features/at-mentions/git-mentions",
|
||||
"features/at-mentions/url-mentions"
|
||||
]
|
||||
},
|
||||
"features/auto-approve",
|
||||
"features/auto-compact",
|
||||
"features/checkpoints",
|
||||
"features/cline-rules",
|
||||
{
|
||||
"group": "Commands & Shortcuts",
|
||||
"pages": [
|
||||
"features/commands-and-shortcuts/overview",
|
||||
"features/commands-and-shortcuts/code-commands",
|
||||
"features/commands-and-shortcuts/terminal-integration",
|
||||
"features/commands-and-shortcuts/git-integration",
|
||||
"features/commands-and-shortcuts/keyboard-shortcuts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Customization",
|
||||
"pages": [
|
||||
"features/customization/opening-cline-in-sidebar",
|
||||
"features/customization/disable-terminal-pagers"
|
||||
]
|
||||
},
|
||||
"features/dictation",
|
||||
"features/drag-and-drop",
|
||||
"features/editing-messages",
|
||||
"features/focus-chain",
|
||||
"features/multiroot-workspace",
|
||||
"features/plan-and-act",
|
||||
{
|
||||
"group": "Slash Commands",
|
||||
"pages": [
|
||||
"features/slash-commands/new-task",
|
||||
"features/slash-commands/new-rule",
|
||||
"features/slash-commands/smol",
|
||||
"features/slash-commands/report-bug",
|
||||
"features/slash-commands/deep-planning"
|
||||
]
|
||||
},
|
||||
"features/slash-commands/workflows",
|
||||
{
|
||||
"group": "Task Management",
|
||||
"pages": [
|
||||
"features/tasks/understanding-tasks",
|
||||
"features/tasks/task-management"
|
||||
]
|
||||
},
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Model & Provider Configuration",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Model Selection",
|
||||
"pages": [
|
||||
"core-features/model-selection-guide",
|
||||
"model-config/model-comparison",
|
||||
"model-config/context-windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Cloud Providers",
|
||||
"pages": [
|
||||
"provider-config/anthropic",
|
||||
"provider-config/claude-code",
|
||||
"provider-config/openai",
|
||||
"provider-config/openrouter",
|
||||
"provider-config/cerebras",
|
||||
"provider-config/deepseek",
|
||||
"provider-config/groq",
|
||||
"provider-config/xai-grok",
|
||||
"provider-config/mistral-ai",
|
||||
"provider-config/doubao",
|
||||
"provider-config/fireworks",
|
||||
"provider-config/zai",
|
||||
"provider-config/gcp-vertex-ai",
|
||||
{
|
||||
"group": "AWS Bedrock",
|
||||
"pages": [
|
||||
"provider-config/aws-bedrock/api-key",
|
||||
"provider-config/aws-bedrock/iam-credentials",
|
||||
"provider-config/aws-bedrock/cli-profile"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Running Models Locally",
|
||||
"pages": [
|
||||
"running-models-locally/overview",
|
||||
"running-models-locally/ollama",
|
||||
"running-models-locally/lm-studio"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Advanced Configuration",
|
||||
"pages": [
|
||||
"provider-config/openai-compatible",
|
||||
"provider-config/litellm-and-cline-using-codestral",
|
||||
"provider-config/vscode-language-model-api",
|
||||
"provider-config/sap-aicore",
|
||||
"provider-config/vercel-ai-gateway",
|
||||
"provider-config/requesty",
|
||||
"provider-config/baseten"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "MCP Integration",
|
||||
"pages": [
|
||||
"mcp/mcp-overview",
|
||||
"mcp/adding-mcp-servers-from-github",
|
||||
"mcp/configuring-mcp-servers",
|
||||
"mcp/connecting-to-a-remote-server",
|
||||
"mcp/mcp-marketplace",
|
||||
"mcp/mcp-server-development-protocol",
|
||||
"mcp/mcp-transport-mechanisms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Cline Tools Reference",
|
||||
"pages": [
|
||||
"exploring-clines-tools/cline-tools-guide",
|
||||
"exploring-clines-tools/new-task-tool",
|
||||
"exploring-clines-tools/remote-browser-support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Enterprise",
|
||||
"pages": [
|
||||
"enterprise-solutions/overview",
|
||||
"enterprise-solutions/security-concerns"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"troubleshooting/terminal-quick-fixes",
|
||||
"troubleshooting/terminal-integration-guide",
|
||||
"more-info/telemetry"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Improving Your Prompting Skills",
|
||||
"pages": [
|
||||
"prompting/prompt-engineering-guide",
|
||||
"prompting/cline-memory-bank"
|
||||
]
|
||||
"tab": "Learn",
|
||||
"icon": "graduation-cap",
|
||||
"href": "https://cline.bot/learn"
|
||||
},
|
||||
{
|
||||
"group": "Features",
|
||||
"pages": [
|
||||
{
|
||||
"group": "@ Mentions",
|
||||
"pages": [
|
||||
"features/at-mentions/overview",
|
||||
"features/at-mentions/file-mentions",
|
||||
"features/at-mentions/terminal-mentions",
|
||||
"features/at-mentions/problem-mentions",
|
||||
"features/at-mentions/git-mentions",
|
||||
"features/at-mentions/url-mentions"
|
||||
]
|
||||
},
|
||||
"features/auto-approve",
|
||||
"features/auto-compact",
|
||||
"features/checkpoints",
|
||||
"features/cline-rules",
|
||||
{
|
||||
"group": "Commands & Shortcuts",
|
||||
"pages": [
|
||||
"features/commands-and-shortcuts/overview",
|
||||
"features/commands-and-shortcuts/code-commands",
|
||||
"features/commands-and-shortcuts/terminal-integration",
|
||||
"features/commands-and-shortcuts/git-integration",
|
||||
"features/commands-and-shortcuts/keyboard-shortcuts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Customization",
|
||||
"pages": [
|
||||
"features/customization/opening-cline-in-sidebar",
|
||||
"features/customization/disable-terminal-pagers"
|
||||
]
|
||||
},
|
||||
"features/dictation",
|
||||
"features/drag-and-drop",
|
||||
"features/editing-messages",
|
||||
"features/focus-chain",
|
||||
"features/plan-and-act",
|
||||
{
|
||||
"group": "Slash Commands",
|
||||
"pages": [
|
||||
"features/slash-commands/new-task",
|
||||
"features/slash-commands/new-rule",
|
||||
"features/slash-commands/smol",
|
||||
"features/slash-commands/report-bug",
|
||||
"features/slash-commands/deep-planning"
|
||||
]
|
||||
},
|
||||
"features/slash-commands/workflows",
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Exploring Cline's Tools",
|
||||
"pages": [
|
||||
"exploring-clines-tools/cline-tools-guide",
|
||||
"exploring-clines-tools/new-task-tool",
|
||||
"exploring-clines-tools/remote-browser-support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Enterprise Solutions",
|
||||
"pages": [
|
||||
"enterprise-solutions/cloud-provider-integration",
|
||||
"enterprise-solutions/custom-instructions",
|
||||
"enterprise-solutions/mcp-servers",
|
||||
"enterprise-solutions/security-concerns"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "MCP Servers",
|
||||
"pages": [
|
||||
"mcp/mcp-overview",
|
||||
"mcp/adding-mcp-servers-from-github",
|
||||
"mcp/configuring-mcp-servers",
|
||||
"mcp/connecting-to-a-remote-server",
|
||||
"mcp/mcp-marketplace",
|
||||
"mcp/mcp-server-development-protocol",
|
||||
"mcp/mcp-transport-mechanisms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Provider Configuration",
|
||||
"pages": [
|
||||
"provider-config/anthropic",
|
||||
"provider-config/claude-code",
|
||||
{
|
||||
"group": "AWS Bedrock",
|
||||
"pages": [
|
||||
"provider-config/aws-bedrock/api-key",
|
||||
"provider-config/aws-bedrock/iam-credentials",
|
||||
"provider-config/aws-bedrock/cli-profile"
|
||||
]
|
||||
},
|
||||
"provider-config/gcp-vertex-ai",
|
||||
"provider-config/litellm-and-cline-using-codestral",
|
||||
"provider-config/vscode-language-model-api",
|
||||
"provider-config/xai-grok",
|
||||
"provider-config/mistral-ai",
|
||||
"provider-config/deepseek",
|
||||
"provider-config/groq",
|
||||
"provider-config/cerebras",
|
||||
"provider-config/doubao",
|
||||
"provider-config/fireworks",
|
||||
"provider-config/zai",
|
||||
"provider-config/ollama",
|
||||
"provider-config/openai",
|
||||
"provider-config/openai-compatible",
|
||||
"provider-config/openrouter",
|
||||
"provider-config/sap-aicore",
|
||||
"provider-config/vercel-ai-gateway",
|
||||
"provider-config/requesty",
|
||||
"provider-config/baseten"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Running Models Locally",
|
||||
"pages": [
|
||||
"running-models-locally/read-me-first",
|
||||
"running-models-locally/lm-studio",
|
||||
"running-models-locally/ollama"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Troubleshooting",
|
||||
"pages": [
|
||||
"troubleshooting/terminal-quick-fixes",
|
||||
"troubleshooting/terminal-integration-guide"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "More Info",
|
||||
"pages": [
|
||||
"more-info/telemetry"
|
||||
]
|
||||
"tab": "Blog",
|
||||
"icon": "newspaper",
|
||||
"href": "https://cline.bot/blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -227,23 +270,54 @@
|
||||
},
|
||||
"anchors": [
|
||||
{
|
||||
"name": "What is Cline",
|
||||
"name": "Overview",
|
||||
"icon": "house",
|
||||
"url": "getting-started/what-is-cline"
|
||||
"url": "introduction/overview"
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/getting-started/installing-cline-jetbrains",
|
||||
"destination": "/getting-started/installing-cline"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/what-is-cline",
|
||||
"destination": "/introduction/overview"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/overview",
|
||||
"destination": "/introduction/overview"
|
||||
},
|
||||
{
|
||||
"source": "/introduction",
|
||||
"destination": "/introduction/welcome"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/model-selection-guide",
|
||||
"destination": "/core-features/model-selection-guide"
|
||||
},
|
||||
{
|
||||
"source": "/provider-config/ollama",
|
||||
"destination": "/running-models-locally/ollama"
|
||||
},
|
||||
{
|
||||
"source": "/running-models-locally/read-me-first",
|
||||
"destination": "/running-models-locally/overview"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/understanding-context-management",
|
||||
"destination": "/prompting/understanding-context-management"
|
||||
},
|
||||
{
|
||||
"source": "/best-practices/understanding-context-management",
|
||||
"destination": "/prompting/understanding-context-management"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/your-first-task",
|
||||
"destination": "/getting-started/your-first-project"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
"prompt": "Search Cline documentation..."
|
||||
},
|
||||
"contextual": {
|
||||
"options": [
|
||||
"copy"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: "Cloud Provider Integration"
|
||||
---
|
||||
|
||||
Cline supports major cloud providers like AWS Bedrock and Google's Cloud Vertex; whichever your team currently uses is appropriate, and there's no need to change providers to utilize Cline's features.
|
||||
|
||||
For the purpose of this document, we assume your organization will use cloud-based frontier models. Cloud inference providers offer cutting-edge capabilities and the flexibility to select models which best suit your needs.
|
||||
|
||||
Certain scenarios may warrant using local models, including handling highly sensitive data, applications requiring consistent low-latency responses, or compliance with strict data sovereignty requirements. If your team needs to utilize local models, see [Running Local Models ](/running-models-locally/read-me-first.mdx)with Cline.
|
||||
|
||||
---
|
||||
|
||||
## AWS Bedrock Setup Guides
|
||||
|
||||
#### [IAM Security Best Practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) (For administrators)
|
||||
|
||||
#### [AWS Bedrock setup for API Keys](/provider-config/aws-bedrock-with-apikey-authentication)
|
||||
|
||||
#### [AWS Bedrock setup for Legacy IAM (AWS Credentials)](/provider-config/aws-bedrock-with-credentials-authentication)
|
||||
|
||||
#### [AWS Bedrock setup for SSO token (AWS Profile)](/provider-config/aws-bedrock-with-profile-authentication)
|
||||
|
||||
#### VPC Endpoint Setup
|
||||
|
||||
To protect your team's data, Cline supports VPC (Virtual Private Cloud) endpoints, which create private connections between your data and AWS Bedrock. AWS VPCs enhance security by eliminating the need for public IP addresses, network gateways, or complex firewall rules—essentially creating a private highway for data that bypasses the public internet entirely. By keeping traffic within AWS's private network, teams also benefit from lower latency and more predictable performance when accessing services like AWS Bedrock or custom APIs. For those working with confidential information or operating in highly regulated industries like healthcare or finance, VPCs offers the perfect balance between the accessibility of cloud services and the security of private infrastructure.
|
||||
|
||||
---
|
||||
|
||||
1. Consult the [AWS guide](https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html) to creating VPC endpoints. This document specifies pre-requisites and describes the syntax used for creating VPC endpoints.
|
||||
2. Follow the directions for [creating a VPC endpoint](https://docs.aws.amazon.com/vpc/latest/privatelink/create-interface-endpoint.html#create-interface-endpoint-aws) in the AWS console. The image below pertains to steps 4 and 5 of the AWS guide linked above.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/vpc-console.png" alt="VPC Console" />
|
||||
</Frame>
|
||||
|
||||
3. Note the IP address of your VPC endpoint, open Cline's settings menu, and select `AWS Bedrock`from the API Provider dropdown.
|
||||
4. Click the `Use Custom VPC endpoint`checkbox and enter the IP address of your VPC endpoint
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/vpc-settings-menu.png" alt="VPC Settings Menu" />
|
||||
</Frame>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
title: "Custom Instructions"
|
||||
---
|
||||
|
||||
## Building Custom Instructions for Teams
|
||||
|
||||
**Creating standardized project instructions ensures that all team members work within consistent guidelines. Start by documenting your project's technical foundation, then identify which information needs to be included in the instructions. The exact scope will vary depending on your team's needs, but generally it's best to provide as much information as possible. By creating comprehensive instructions that all team members follow, you establish a shared understanding of how code should be written, tested, and deployed across your project, resulting in more maintainable and consistent software.**
|
||||
|
||||
---
|
||||
|
||||
Here are a few topics and examples to consider for your team's custom instructions:
|
||||
|
||||
1. **Testing framework and specific commands**
|
||||
- "All components must include Jest tests with at least 85% coverage. Run tests using `npm run test:coverage` before submitting any pull request."
|
||||
2. **Explicit library preferences**
|
||||
- "Use React Query for data fetching and state management. Avoid Redux unless specifically required for complex global state. For styling, use Tailwind CSS with our custom theme configuration found in `src/styles/theme.js.`"
|
||||
3. **Where to find documentation**
|
||||
- "All API documentation is available in our internal Notion workspace under 'Engineering > API Reference'. For component usage examples, refer to our Storybook instance at `https://storybook.internal.company.com`"
|
||||
4. **Which MCP servers to use, and for which purposes**
|
||||
- "For database operations, use the Postgres MCP server with credentials stored in 1Password under 'Development > Database'. For deployments, use the AWS MCP server which requires the deployment role from IAM. Refer to `docs/mcp-setup.md` for configuration instructions."
|
||||
5. **Coding conventions specific to your project**
|
||||
- "Name all React components using PascalCase and all helper functions using camelCase. Place components in the `src/components` directory organized by feature, not by type. Always use TypeScript interfaces for prop definitions."
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
title: "MCP Servers"
|
||||
---
|
||||
|
||||
**Model Context Protocol (MCP) servers expand Cline's capabilities by providing standardized access to external data sources and executable functions. By implementing MCP servers, LLM tools can dynamically retrieve and incorporate relevant information from both local and remote data sources. This capability ensures that the models operate with the most current and contextually appropriate data, improving the accuracy and relevance of their outputs.**
|
||||
|
||||
---
|
||||
|
||||
### Secure Architecture Fundamentals
|
||||
|
||||
MCP servers follow a client-server architecture where hosts (LLM applications like Cline) initiate connections through a transport layer to MCP servers. This architecture inherently provides security benefits as it maintains clear separation between components. Enterprise deployments should focus on the proper implementation of this architecture to ensure secure operations, particularly regarding the message exchange patterns and connection lifecycle management. For MCP architecture details, see [MCP Architecture](https://modelcontextprotocol.io/docs/concepts/architecture), and for latest specifications, see [MCP Specifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/).
|
||||
|
||||
### Transport Layer Security
|
||||
|
||||
For enterprise environments, selecting the appropriate transport mechanism is crucial. While stdio transport works efficiently for local processes, HTTP with Server-Sent Events (SSE) transport requires additional security measures. TLS should be used for all remote connections whenever possible. This is especially important when MCP servers are deployed across different network segments within corporate infrastructure.
|
||||
|
||||
### Message Validation and Access Control
|
||||
|
||||
The MCP architecture defines standard error codes and message types (Requests, Results, Errors, and Notifications), providing a structured framework for secure communication. Security teams should consider message validation, sanitizing inputs, checking message size limits, and verifying JSON-RPC format. Additionally, implementing resource protection through access controls, path validation, and request rate limiting helps prevent potential abuse of MCP server capabilities.
|
||||
|
||||
### Monitoring and Compliance
|
||||
|
||||
For enterprise compliance requirements, implementing comprehensive logging of protocol events, message flows, and errors is essential. The MCP architecture supports diagnostic capabilities including health checks, connection state monitoring, and resource usage tracking. Organizations should extend these capabilities to meet their specific compliance needs, particularly for audit trails of all MCP server interactions and resource access patterns.
|
||||
|
||||
By leveraging the client-server design of the MCP architecture and implementing appropriate security controls at each layer, enterprises can safely integrate MCP servers into their environments while maintaining their security posture and meeting regulatory requirements.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "Cline Enterprise"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Enterprise security, governance, and observability for the coding agent 3 million developers trust"
|
||||
---
|
||||
|
||||
Cline Enterprise brings centralized governance to the same open-source architecture that millions of developers already use. Your code stays in your environment, you use your own inference at your negotiated rates, and you get the security and observability capabilities that platform teams need for org-wide deployment.
|
||||
|
||||
<Card title="Learn More About Enterprise" icon="building" href="https://cline.bot/enterprise">
|
||||
Visit our website for detailed information about enterprise features, pricing, and deployment options.
|
||||
</Card>
|
||||
|
||||
## What You Get
|
||||
|
||||
It delivers five core capabilities that platform teams need for production deployment. Each addresses a specific requirement for scaling AI coding across your organization.
|
||||
|
||||
### Security by Design
|
||||
|
||||
Your code never leaves your environment. Cline processes everything locally - no uploads, no indexing, no training on your data.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Client-side execution" icon="computer">
|
||||
All processing happens within your environment
|
||||
</Card>
|
||||
|
||||
<Card title="No data exfiltration" icon="shield-check">
|
||||
Code and context never transmitted externally
|
||||
</Card>
|
||||
|
||||
<Card title="No codebase indexing" icon="database">
|
||||
Repositories are never indexed or cached
|
||||
</Card>
|
||||
|
||||
<Card title="No model training" icon="ban">
|
||||
Your code and prompts aren't used for training
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
### Bring Your Own Inference
|
||||
|
||||
Use your existing cloud contracts and negotiated rates. Most AI tools force you to buy inference through them with markup. Cline connects directly to your providers.
|
||||
|
||||
Connect to any inference provider:
|
||||
- AWS Bedrock
|
||||
- Google Vertex AI
|
||||
- Azure OpenAI
|
||||
- Anthropic direct
|
||||
- OpenAI direct
|
||||
- Cerebras
|
||||
- Any OpenAI-compatible endpoint
|
||||
|
||||
Switch models instantly as new ones release. Use Claude Sonnet 4.5 as your daily driver, GPT-5 for complex refactoring, open-source models for simple tasks. Your existing cloud credits and startup program contracts now cover AI coding. We handle the agent loop. You handle the inference. No markup, no vendor lock-in.
|
||||
|
||||
### Governance at Scale
|
||||
|
||||
Platform teams need central control when thousands of developers use AI. Individual API keys scattered across laptops create security risks and cost overruns.
|
||||
|
||||
Enterprise governance provides:
|
||||
- **SSO authentication**: Corporate credentials instead of personal API keys
|
||||
- **Role-based access control**: Fine-grained permissions per team and project
|
||||
- **Model and tool controls**: Govern which models and tools each team accesses
|
||||
- **Remote configuration**: Manage settings for all developers from one dashboard
|
||||
- **Full audit logging**: Every AI interaction tracked with detailed logs
|
||||
|
||||
Configure once, deploy everywhere. Developers work how they prefer while you maintain control.
|
||||
|
||||
### Complete Observability
|
||||
|
||||
Export logs to your existing observability stack. Track usage, costs, and performance across all teams.
|
||||
|
||||
- **OpenTelemetry export**: Direct integration with Datadog, Grafana, Splunk
|
||||
- **Real-time analytics**: Track adoption, performance, and patterns
|
||||
- **Cost breakdown**: See exactly what each team spends on which models
|
||||
- **JSON output**: Build custom dashboards in your existing tools
|
||||
|
||||
The same observability standards you require for production systems.
|
||||
|
||||
## Deployment
|
||||
|
||||
Cline Enterprise connects securely to your infrastructure. Deploy in cloud environments, on-premises, or air-gapped networks. Configure to work with your existing security policies and compliance requirements.
|
||||
|
||||
Rolling out to your organization:
|
||||
1. Configure Cline Core to connect to your infrastructure
|
||||
2. Set SSO, RBAC, and governance policies
|
||||
3. Deploy to developers via your existing software distribution
|
||||
4. Monitor usage through your observability tools
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review [security architecture](/enterprise-solutions/security-concerns)
|
||||
- Configure [cloud provider setup](/provider-config/aws-bedrock/api-key) (AWS Bedrock, Vertex AI, Azure)
|
||||
- Set up [MCP servers](/mcp/mcp-overview) for custom tooling
|
||||
- Add [custom instructions](/features/cline-rules) for your codebase
|
||||
|
||||
Schedule a walkthrough to see how Cline Enterprise fits your infrastructure. We'll work with your security and compliance requirements to deploy in your environment.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user