mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e5b9cdea | |||
| 04549cf1e3 | |||
| f2a93b6201 | |||
| 2f09b994dd | |||
| 8aac67bf75 | |||
| 10422dd3fc | |||
| 6191c681ce | |||
| 9ab7b41c24 | |||
| bddfb93b38 | |||
| a219eb8000 | |||
| 515b4e5d6a | |||
| aaa3846b82 | |||
| 8229dcfe88 | |||
| b4ea1a17a5 | |||
| e263c74325 | |||
| 08ca115c66 | |||
| 68ffe20b7d | |||
| 4517408537 | |||
| 42c947afd4 | |||
| d4736afce2 | |||
| b00053ff16 | |||
| 2ab8fcb54f | |||
| e697c12e94 | |||
| 64dcc3fc89 | |||
| 8215d3fdb9 | |||
| 59260cefab | |||
| a767f844b9 | |||
| bb7c3556d6 | |||
| 8b80c337ae | |||
| d0cac53d5b | |||
| 8aedc90214 | |||
| 0c118086df | |||
| 261a7dd505 | |||
| 9abf2f567a | |||
| 42de7c81b4 | |||
| 06b710f62b | |||
| ea9b8fe0b1 | |||
| 05213f2a71 | |||
| d5bad1357d | |||
| a4b1549dac | |||
| e015ce94c0 | |||
| be076cf407 | |||
| 580db36476 | |||
| 8f8b98bb58 | |||
| 36022438cb | |||
| c7afb61e28 | |||
| dfd113a6e5 | |||
| 3698d2356c | |||
| ff20c4addc | |||
| 8eeeabb966 | |||
| 9b1dc5bd92 | |||
| 1cfff0a45f | |||
| e7d00dec2d | |||
| 2c5748ccfd | |||
| 5196adce33 | |||
| d95d86f329 | |||
| f6eb3aa386 | |||
| 5c4b9e54c2 | |||
| 29a1d08685 | |||
| 9664ddd106 | |||
| 3c1327b115 | |||
| bb993e4a9a | |||
| 473b3d0204 | |||
| 7e68614631 | |||
| a19e9907d5 | |||
| 19f74cac01 | |||
| b877abc708 | |||
| c90e64e763 | |||
| e02e1eca7f | |||
| f8925fb8fd | |||
| ac81aeaf4e | |||
| f12b5a1573 | |||
| a66b57ef51 | |||
| 944ed41f4a | |||
| 970e941e57 | |||
| 45abe977e4 | |||
| 5755b30bce | |||
| 5fde21dffd | |||
| cf9f2a8630 | |||
| 5a3416ff09 | |||
| 826b2b1276 | |||
| 8da38b2a2e | |||
| cb121170cb | |||
| e9d2d344c6 | |||
| 69fb954a6c | |||
| e9e616e317 | |||
| 545ac29e07 | |||
| d38489aebc | |||
| 8d47026640 | |||
| 268cd5c527 | |||
| c7c4e43322 | |||
| aae9d432fd | |||
| 604dbd7bb0 | |||
| 062a32f93d | |||
| 535b29f465 | |||
| a8027dc570 | |||
| 978a8a0aa6 | |||
| 0cd462a414 | |||
| 65dbd85a92 | |||
| ee1bb2f788 | |||
| 6f69ffb16f | |||
| f91769bda7 | |||
| 7692adacf5 | |||
| a98faf5af4 | |||
| e3f4ce618f | |||
| dcf519d2f7 | |||
| 3ef4aea0f7 | |||
| a820026e0b | |||
| 29d1b0507c | |||
| 929d13a4dd | |||
| c729e8c7c6 | |||
| 737452b2b1 | |||
| ba6a72cf15 | |||
| 65a0c35163 | |||
| 0c8e02c6e4 | |||
| b636018ef5 | |||
| cfc6b0d7f5 | |||
| f116a6323d | |||
| 46d3b2a3ed | |||
| 9679917532 | |||
| 89bf81f7f6 | |||
| afe01df8b4 | |||
| 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 | |||
| 9be3fa5acf | |||
| e76d9527c8 | |||
| e8b8ec4f05 | |||
| 664ec1e0ac | |||
| e34c62ade9 | |||
| ffabde6985 | |||
| a8c62dddc6 | |||
| 2b5d6e5d0e | |||
| 0440898584 | |||
| 3288defb67 | |||
| ec6daccf5f | |||
| e399a70bd0 | |||
| b52edae01e | |||
| 467838ac4c | |||
| 9be95f2401 | |||
| 814988c929 | |||
| 7b7a006123 | |||
| 08286a8465 | |||
| d252c84a04 | |||
| 9cd2729d47 | |||
| bb94a572ac | |||
| a7ec39270b | |||
| 600bcab19a | |||
| 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 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Use http_proxy, https_proxy and no_proxy in JetBrains and CLI mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added getCwdHash proto
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixing the logic for Oca Token Refresh
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added updateApiConfigurationPartial with FieldMask to allow for partial ApiProvider updates
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
add new provider AIhubmix
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add auto-retry with exponential backof for failed API requests
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issues where assistant message with empty content is added to conversation history
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fixes a bug where the checkbox shows in the model selector dropdown.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added subscribeToCheckpoints proto
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
auto-cleanup stale default instance config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
switch from defaultUserAgentProvider to customUserAgent for Bedrock
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add interactive provider configuration wizard with add/list capabilities, support for 8 API providers (Anthropic, OpenAI, OpenAI Native, OpenRouter, X AI, AWS Bedrock, Google Gemini, Ollama), and UpdateSettings gRPC implementation for persisting configurations to Cline Core state.
|
||||
@@ -1,54 +1,15 @@
|
||||
#!/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)
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
# 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')
|
||||
sleep 3
|
||||
|
||||
# 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."
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
@echo off
|
||||
REM PostToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs AFTER a tool is executed. It can:
|
||||
REM 1. Observe tool results and outcomes
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Log or track tool usage patterns
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
REM The tool has already completed when this hook runs.
|
||||
|
||||
REM Simple example: Always allow continuation
|
||||
echo {"shouldContinue": true}
|
||||
|
||||
REM To add context based on results, use:
|
||||
REM echo {"shouldContinue": true, "contextModification": "TOOL_RESULT: Operation completed successfully"}
|
||||
@@ -1,38 +0,0 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook - Advanced Example with Input Parsing
|
||||
REM This version reads and parses the JSON input from stdin using PowerShell
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read all input from stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse JSON and make decisions using PowerShell
|
||||
REM Note: We use -replace to handle special characters in the input
|
||||
powershell -NoProfile -Command ^
|
||||
"$input = '%INPUT%' -replace \"'\", \"''\"; ^
|
||||
try { ^
|
||||
$json = $input | ConvertFrom-Json; ^
|
||||
$toolName = $json.preToolUse.toolName; ^
|
||||
$shouldBlock = $false; ^
|
||||
$errorMsg = ''; ^
|
||||
$context = ''; ^
|
||||
if ($toolName -eq 'write_to_file') { ^
|
||||
$path = $json.preToolUse.parameters.path; ^
|
||||
if ($path -match '\\.js$') { ^
|
||||
$shouldBlock = $true; ^
|
||||
$errorMsg = 'Cannot create .js files in TypeScript project'; ^
|
||||
$context = 'WORKSPACE_RULES: Use .ts/.tsx extensions only'; ^
|
||||
} ^
|
||||
} ^
|
||||
$output = @{ ^
|
||||
shouldContinue = -not $shouldBlock; ^
|
||||
}; ^
|
||||
if ($errorMsg) { $output.errorMessage = $errorMsg }; ^
|
||||
if ($context) { $output.contextModification = $context }; ^
|
||||
$output | ConvertTo-Json -Compress; ^
|
||||
} catch { ^
|
||||
@{ shouldContinue = $true } | ConvertTo-Json -Compress; ^
|
||||
}"
|
||||
|
||||
endlocal
|
||||
@@ -1,42 +1,15 @@
|
||||
#!/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)
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
|
||||
sleep 3
|
||||
|
||||
# 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."
|
||||
"cancel": false,
|
||||
"contextModification": "Make sure that all variables in python code in this repo always start with the string 'EVIEDEBUG_'. This is important.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs BEFORE a tool is executed. It can:
|
||||
REM 1. Block execution by returning {"shouldContinue": false}
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Validate tool parameters
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
|
||||
REM Simple example: Always allow execution with workspace context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: This is a TypeScript project. Use .ts/.tsx extensions for new files."}
|
||||
|
||||
REM To block execution, use:
|
||||
REM echo {"shouldContinue": false, "errorMessage": "Operation not allowed"}
|
||||
+222
-56
@@ -2,7 +2,11 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks are placed in the `.clinerules/hooks/` directory and run automatically when enabled.
|
||||
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/Hooks/` (applies to all workspaces)
|
||||
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to specific workspace)
|
||||
|
||||
Hooks run automatically when enabled.
|
||||
|
||||
## Enabling Hooks
|
||||
|
||||
@@ -13,62 +17,93 @@ Cline hooks allow you to execute custom scripts at specific points in the agenti
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### TaskStart Hook
|
||||
- **When**: Runs when a NEW task is started (not when resuming)
|
||||
- **Purpose**: Initialize task context, validate task requirements, set up environment
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/TaskStart` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
|
||||
|
||||
### TaskResume Hook
|
||||
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
|
||||
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/TaskResume` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
|
||||
|
||||
### TaskCancel Hook
|
||||
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
|
||||
- **Purpose**: Clean up resources, log cancellation, save state
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/TaskCancel` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
|
||||
- **Note**: This hook is NOT cancellable and will complete even if the task is being aborted
|
||||
|
||||
### TaskComplete Hook
|
||||
- **When**: Runs when a task is marked as complete
|
||||
- **Purpose**: Log completion status, perform final cleanup, generate reports
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/TaskComplete` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/TaskComplete` (all platforms)
|
||||
|
||||
### UserPromptSubmit Hook
|
||||
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
|
||||
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/UserPromptSubmit` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
|
||||
|
||||
### PreToolUse Hook
|
||||
- **When**: Runs BEFORE a tool is executed
|
||||
- **Purpose**: Validate parameters, block execution, or add context
|
||||
- **File**: `.clinerules/hooks/PreToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PreToolUse.bat/.cmd/.exe` (Windows)
|
||||
- **Global Location**: `~/Documents/Cline/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
|
||||
- **File**: `.clinerules/hooks/PostToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PostToolUse.bat/.cmd/.exe` (Windows)
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/PostToolUse` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
|
||||
|
||||
## Platform-Specific Guidance
|
||||
### PreCompact Hook
|
||||
- **When**: Runs BEFORE the conversation context is compacted/truncated
|
||||
- **Purpose**: Observe compaction events, log context management, track token usage
|
||||
- **Global Location**: `~/Documents/Cline/Hooks/PreCompact` (all platforms)
|
||||
- **Workspace Location**: `.clinerules/hooks/PreCompact` (all platforms)
|
||||
|
||||
### Windows Hooks
|
||||
## Cross-Platform Hook Format
|
||||
|
||||
Windows hooks use different file extensions and syntax than Unix hooks. Cline automatically searches for hooks using your system's `PATHEXT` environment variable (typically `.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.MSC`).
|
||||
Cline uses a git-style approach for hooks that works consistently across all platforms:
|
||||
|
||||
**Recommended approach for Windows:**
|
||||
- Use `.cmd` or `.bat` batch files (most compatible)
|
||||
- See `PreToolUse.example.cmd` and `PostToolUse.example.cmd` for simple examples
|
||||
- See `PreToolUse.advanced.example.cmd` for PowerShell-based JSON parsing
|
||||
### 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
|
||||
|
||||
**Simple Windows Hook Example:**
|
||||
```batch
|
||||
@echo off
|
||||
REM Always allow execution with context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: TypeScript project"}
|
||||
### 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/Hooks/PreToolUse
|
||||
|
||||
# Make executable
|
||||
chmod +x ~/Documents/Cline/Hooks/PreToolUse
|
||||
```
|
||||
|
||||
**Advanced Windows Hook with Input Parsing:**
|
||||
**On Windows:**
|
||||
```batch
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse and process JSON
|
||||
powershell -Command ^
|
||||
"$json = '%INPUT%' | ConvertFrom-Json; ^
|
||||
$output = @{shouldContinue = $true}; ^
|
||||
$output | ConvertTo-Json -Compress"
|
||||
REM Create hook file (note: no file extension)
|
||||
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
|
||||
```
|
||||
|
||||
**Tips for Windows:**
|
||||
- Batch files don't require `chmod +x` - they're executable by default
|
||||
- Use `REM` for comments instead of `#`
|
||||
- PowerShell is available on all modern Windows systems
|
||||
- For complex logic, consider PowerShell scripts (`.ps1`) or compiled executables (`.exe`)
|
||||
|
||||
### Unix/Linux/macOS Hooks
|
||||
|
||||
Unix hooks are shell scripts without file extensions:
|
||||
- Must be executable: `chmod +x PreToolUse`
|
||||
- Must include shebang: `#!/usr/bin/env bash` or `#!/usr/bin/env node`
|
||||
- See `PreToolUse.example` and `PostToolUse.example` for bash examples
|
||||
|
||||
## Context Injection Timing
|
||||
|
||||
**IMPORTANT**: Context injected by hooks affects **FUTURE AI decisions**, not the current tool execution.
|
||||
@@ -109,11 +144,46 @@ All hooks receive:
|
||||
```json
|
||||
{
|
||||
"clineVersion": "string",
|
||||
"hookName": "PreToolUse" | "PostToolUse",
|
||||
"hookName": "TaskStart" | "TaskResume" | "TaskCancel" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact",
|
||||
"timestamp": "string",
|
||||
"taskId": "string",
|
||||
"workspaceRoots": ["string"],
|
||||
"userId": "string",
|
||||
"taskStart": { // Only for TaskStart
|
||||
"taskMetadata": {
|
||||
"taskId": "string",
|
||||
"ulid": "string",
|
||||
"initialTask": "string"
|
||||
}
|
||||
},
|
||||
"taskResume": { // Only for TaskResume
|
||||
"taskMetadata": {
|
||||
"taskId": "string",
|
||||
"ulid": "string"
|
||||
},
|
||||
"previousState": {
|
||||
"lastMessageTs": "string",
|
||||
"messageCount": "string",
|
||||
"conversationHistoryDeleted": "string"
|
||||
}
|
||||
},
|
||||
"taskCancel": { // Only for TaskCancel
|
||||
"taskMetadata": {
|
||||
"taskId": "string",
|
||||
"ulid": "string",
|
||||
"completionStatus": "string"
|
||||
}
|
||||
},
|
||||
"taskComplete": { // Only for TaskComplete
|
||||
"taskMetadata": {
|
||||
"taskId": "string",
|
||||
"ulid": "string"
|
||||
}
|
||||
},
|
||||
"userPromptSubmit": { // Only for UserPromptSubmit
|
||||
"prompt": "string",
|
||||
"attachments": ["string"]
|
||||
},
|
||||
"preToolUse": { // Only for PreToolUse
|
||||
"toolName": "string",
|
||||
"parameters": {}
|
||||
@@ -124,6 +194,11 @@ All hooks receive:
|
||||
"result": "string",
|
||||
"success": boolean,
|
||||
"executionTimeMs": number
|
||||
},
|
||||
"preCompact": { // Only for PreCompact
|
||||
"contextSize": number,
|
||||
"messagesToCompact": number,
|
||||
"compactionStrategy": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -133,12 +208,16 @@ All hooks receive:
|
||||
All hooks must return:
|
||||
```json
|
||||
{
|
||||
"shouldContinue": boolean, // Required: Allow or block execution
|
||||
"contextModification": "string", // Optional: Context for future tool uses
|
||||
"cancel": boolean, // Required: false to continue, true to block execution
|
||||
"contextModification": "string", // Optional: Context for future AI decisions
|
||||
"errorMessage": "string" // Optional: Error details if blocking
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `cancel` field works as follows:
|
||||
- `false` (or omitted): Allow execution to continue
|
||||
- `true`: Block execution and show error message to user
|
||||
|
||||
## Context Modification Format
|
||||
|
||||
Use structured prefixes to help the AI understand context type:
|
||||
@@ -154,7 +233,7 @@ Example:
|
||||
```bash
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
}
|
||||
EOF
|
||||
@@ -162,9 +241,9 @@ 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
|
||||
- **Timeout**: Hooks must complete within 30 seconds (configurable via `HOOK_EXECUTION_TIMEOUT_MS`)
|
||||
- **Context Size**: Context modifications are limited to 50KB (configurable via `MAX_CONTEXT_MODIFICATION_SIZE`)
|
||||
- **Error Handling**: Expected errors (file not found, permission denied, not a directory) are handled silently; unexpected file system errors are propagated
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
@@ -179,7 +258,7 @@ path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
|
||||
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": false,
|
||||
"cancel": true,
|
||||
"errorMessage": "Cannot create .js files in TypeScript project",
|
||||
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
|
||||
}
|
||||
@@ -187,7 +266,7 @@ EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
echo '{"cancel": false}'
|
||||
```
|
||||
|
||||
### 2. Context Building - Learn from Operations
|
||||
@@ -202,12 +281,12 @@ path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
|
||||
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"cancel": false,
|
||||
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
echo '{"cancel": false}'
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -222,12 +301,12 @@ tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
|
||||
if [[ "$execution_time" -gt 5000 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"cancel": false,
|
||||
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
echo '{"cancel": false}'
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -241,17 +320,104 @@ input=$(cat)
|
||||
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
|
||||
|
||||
# Allow execution
|
||||
echo '{"shouldContinue": true}'
|
||||
echo '{"cancel": false}'
|
||||
```
|
||||
|
||||
## Global vs Workspace Hooks
|
||||
|
||||
Cline supports two levels of hooks:
|
||||
|
||||
### Global Hooks
|
||||
- **Location**: `~/Documents/Cline/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Hooks\` (Windows)
|
||||
- **Scope**: Apply to ALL workspaces and projects
|
||||
- **Use Case**: Organization-wide policies, personal preferences, universal validations
|
||||
- **Priority**: Order not guaranteed when combined with 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**: Order not guaranteed when combined with global hooks
|
||||
|
||||
### Hook Execution
|
||||
|
||||
When multiple hooks exist (global and/or workspace):
|
||||
- All hooks for a given step are executed **concurrently** using `Promise.all`
|
||||
- **Execution order is not guaranteed** - hooks run in parallel
|
||||
- If ALL hooks allow execution (`cancel: false`), the tool proceeds
|
||||
- If ANY hook blocks (`cancel: true`), execution is blocked
|
||||
|
||||
**Result Combination:**
|
||||
- `cancel`: If ANY hook returns `true`, execution is blocked
|
||||
- `contextModification`: All context strings are concatenated with double newlines (`\n\n`)
|
||||
- `errorMessage`: All error messages are concatenated with single newlines (`\n`)
|
||||
|
||||
### Setting Up Global Hooks
|
||||
|
||||
1. The global hooks directory is automatically created at:
|
||||
- macOS/Linux: `~/Documents/Cline/Hooks/`
|
||||
- Windows: `%USERPROFILE%\Documents\Cline\Hooks\`
|
||||
|
||||
2. Add your hook script:
|
||||
```bash
|
||||
# Unix/Linux/macOS
|
||||
nano ~/Documents/Cline/Hooks/PreToolUse
|
||||
chmod +x ~/Documents/Cline/Hooks/PreToolUse
|
||||
|
||||
# Windows
|
||||
notepad %USERPROFILE%\Documents\Cline\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/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 '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"cancel": false}'
|
||||
```
|
||||
|
||||
**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 '{"cancel": true, "errorMessage": "Project rule: Use .ts files only"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"cancel": false}'
|
||||
```
|
||||
|
||||
**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 will run and their results will be combined:
|
||||
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
|
||||
- **cancel**: If ANY hook returns `true`, 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
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel", false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 1
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
for i in {1..100}; do
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
sleep 3
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "WORKSPACE_RULES: This is from the local bar/ workspace.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage: foo"
|
||||
}
|
||||
EOF
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,74 @@ ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -72,4 +72,11 @@ 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: console,otlp
|
||||
OTEL_METRICS_EXPORTER: console,otlp
|
||||
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 }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
|
||||
@@ -97,6 +97,13 @@ 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: console,otlp
|
||||
OTEL_METRICS_EXPORTER: console,otlp
|
||||
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 }}
|
||||
run: |
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
name: Release Standalone CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., v3.32.6)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-13
|
||||
platform: darwin-x64
|
||||
arch: x64
|
||||
- os: macos-14
|
||||
platform: darwin-arm64
|
||||
arch: arm64
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
arch: x64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download Node.js binaries
|
||||
run: npm run download-node
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Build standalone CLI package
|
||||
run: npm run compile-standalone-cli
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Rename package
|
||||
run: |
|
||||
cd dist-standalone
|
||||
mv standalone-cli.zip cline-${{ steps.version.outputs.version }}-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-${{ matrix.platform }}
|
||||
path: dist-standalone/cline-${{ steps.version.outputs.version }}-${{ matrix.platform }}.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Display structure
|
||||
run: ls -R artifacts/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: Cline CLI ${{ steps.version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/cline-darwin-x64/cline-${{ steps.version.outputs.version }}-darwin-x64.tar.gz
|
||||
artifacts/cline-darwin-arm64/cline-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz
|
||||
artifacts/cline-linux-x64/cline-${{ steps.version.outputs.version }}-linux-x64.tar.gz
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
Install Cline CLI with a single command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/cline/cline/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Platform-Specific Downloads
|
||||
|
||||
- **macOS (Intel)**: `cline-${{ steps.version.outputs.version }}-darwin-x64.tar.gz`
|
||||
- **macOS (Apple Silicon)**: `cline-${{ steps.version.outputs.version }}-darwin-arm64.tar.gz`
|
||||
- **Linux (x64)**: `cline-${{ steps.version.outputs.version }}-linux-x64.tar.gz`
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the appropriate package for your platform
|
||||
2. Extract: `tar -xzf cline-*.tar.gz`
|
||||
3. Move to installation directory: `mv cline-* ~/.cline`
|
||||
4. Add to PATH: `export PATH="$HOME/.cline/bin:$PATH"`
|
||||
|
||||
### What's Included
|
||||
|
||||
- ✅ Node.js v22.15.0 (bundled)
|
||||
- ✅ Cline CLI binary
|
||||
- ✅ Cline Host bridge
|
||||
- ✅ Cline Core (TypeScript compiled)
|
||||
- ✅ All dependencies
|
||||
|
||||
### Getting Started
|
||||
|
||||
```bash
|
||||
# Verify installation
|
||||
cline version
|
||||
|
||||
# Sign in
|
||||
cline auth login
|
||||
|
||||
# Get help
|
||||
cline --help
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Installation Guide](https://docs.cline.bot/getting-started/installing-cline)
|
||||
- [CLI Documentation](https://docs.cline.bot/exploring-clines-tools/cline-tools-guide)
|
||||
- [GitHub Repository](https://github.com/cline/cline)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
@@ -193,14 +193,14 @@ jobs:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Download Node.js binaries
|
||||
run: npm run download-node
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Compile standalone CLI
|
||||
run: npm run compile-standalone-cli
|
||||
- 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'
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## [3.35.1]
|
||||
|
||||
- Add: Hicap API integration as provider
|
||||
- Fix: enable Add Header button in OpenAICompatibleProvider UI
|
||||
- Fix: Remove orphaned tool_results after truncation and empty content field issues in native tool call
|
||||
- Fix: render model description in markdown
|
||||
|
||||
## [3.35.0]
|
||||
|
||||
- Add native tool calling support with configurable setting.
|
||||
- Auto-approve is now always-on with a redesigned expanding menu. Settings simplified and notifications moved to General Settings.
|
||||
- added zai-glm-4.6 as a Cerebras model
|
||||
- Created GPT5 family specific system prompt template
|
||||
- Fix: show reasoning budget slider to models with valid thinking config
|
||||
- Requesty base URL, and API key fixes
|
||||
- Delete all Auth Tokens when logging out
|
||||
- Support for <think> tags for models that prefer that over <thinking>
|
||||
|
||||
## [3.34.1]
|
||||
|
||||
- Added support for MiniMax provider with MiniMax-M2 model
|
||||
- Remove Cline/code-supernova-1-million model
|
||||
- Changes to allow users to manually enter model names (eg. presets) when using OpenRouter
|
||||
|
||||
## [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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 on OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
@@ -43,7 +43,7 @@ Thanks to [Claude Sonnet's agentic coding capabilities](https://www.anthropic.c
|
||||
4. When a task is completed, Cline will present the result to you with a terminal command like `open -a "Google Chrome" index.html`, which you run with a click of a button.
|
||||
|
||||
> [!TIP]
|
||||
> Use the `CMD/CTRL + Shift + P` shortcut to open the command palette and type "Cline: Open In New Tab" to open the extension as a tab in your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
|
||||
> Follow [this guide](https://docs.cline.bot/features/customization/opening-cline-in-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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 @@
|
||||
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
|
||||
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
|
||||
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
|
||||
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
|
||||
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
|
||||
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
|
||||
+242
-33
@@ -2,14 +2,20 @@ 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"
|
||||
)
|
||||
|
||||
@@ -19,12 +25,12 @@ var (
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -36,10 +42,17 @@ func main() {
|
||||
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.`,
|
||||
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" {
|
||||
@@ -55,16 +68,76 @@ This CLI also provides task management, configuration, and monitoring capabiliti
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var prompt string
|
||||
var instanceAddress string
|
||||
|
||||
// If args provided, use as prompt
|
||||
if len(args) > 0 {
|
||||
prompt = strings.Join(args, " ")
|
||||
} else {
|
||||
// Show interactive input to get prompt
|
||||
var err error
|
||||
prompt, err = promptForInitialTask()
|
||||
// If --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 == "" {
|
||||
@@ -72,53 +145,68 @@ This CLI also provides task management, configuration, and monitoring capabiliti
|
||||
}
|
||||
}
|
||||
|
||||
// Create task + follow
|
||||
// Don't pass address unless explicitly set via --address flag
|
||||
// This allows the default instance resolution logic to work
|
||||
var addr string
|
||||
if cmd.Flags().Changed("address") {
|
||||
addr = coreAddress
|
||||
// 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,
|
||||
Workspaces: workspaces,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: addr, // Empty string means use default instance
|
||||
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().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().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan")
|
||||
rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)")
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.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.NewTaskSendCommand())
|
||||
rootCmd.AddCommand(cli.NewLogsCommand())
|
||||
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask() (string, error) {
|
||||
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().
|
||||
@@ -128,12 +216,133 @@ func promptForInitialTask() (string, error) {
|
||||
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
|
||||
|
||||
+13
-7
@@ -4,10 +4,14 @@ 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.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
|
||||
@@ -21,11 +25,8 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/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
|
||||
@@ -33,6 +34,7 @@ require (
|
||||
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
|
||||
@@ -44,7 +46,7 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/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
|
||||
@@ -55,4 +57,8 @@ require (
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
+24
-12
@@ -10,26 +10,26 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/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.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/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.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
||||
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
||||
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.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
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=
|
||||
@@ -57,6 +57,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
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=
|
||||
@@ -65,6 +67,8 @@ 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=
|
||||
@@ -82,8 +86,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
@@ -96,6 +98,8 @@ 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=
|
||||
@@ -148,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.3",
|
||||
"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": ">=20.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"
|
||||
]
|
||||
}
|
||||
+30
-4
@@ -6,12 +6,38 @@ import (
|
||||
)
|
||||
|
||||
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 auth.HandleAuthCommand(cmd.Context(), args)
|
||||
return auth.RunAuthFlow(cmd.Context(), 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
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ var isSessionAuthenticated bool
|
||||
// Cline provider specific code
|
||||
|
||||
func HandleClineAuth(ctx context.Context) error {
|
||||
fmt.Println("Authenticating with Cline...")
|
||||
verboseLog("Authenticating with Cline...")
|
||||
|
||||
// Check if already authenticated
|
||||
if IsAuthenticated(ctx) {
|
||||
@@ -28,7 +28,10 @@ func HandleClineAuth(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✓ You are signed in!")
|
||||
fmt.Println()
|
||||
|
||||
verboseLog("✓ You are signed in!")
|
||||
|
||||
|
||||
// Configure default Cline model after successful authentication
|
||||
if err := configureDefaultClineModel(ctx); err != nil {
|
||||
@@ -84,15 +87,6 @@ func signIn(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
// Subscribe to auth updates before initiating login
|
||||
verboseLog("Subscribing to auth status updates...")
|
||||
listener, err := NewAuthStatusListener(ctx)
|
||||
@@ -115,13 +109,16 @@ func signIn(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
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)")
|
||||
|
||||
@@ -195,3 +192,94 @@ func configureDefaultClineModel(ctx context.Context) error {
|
||||
// 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)
|
||||
}
|
||||
+112
-30
@@ -5,20 +5,27 @@ import (
|
||||
"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"
|
||||
AuthActionSelectProvider AuthAction = "select_provider"
|
||||
AuthActionExit AuthAction = "exit_wizard"
|
||||
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
|
||||
@@ -32,27 +39,67 @@ const (
|
||||
// ┃ Change Cline model (only if authenticated) - hidden if not authenticated
|
||||
// ┃ Authenticate with Cline account / Sign out of Cline - changes based on auth status
|
||||
// ┃ Select active provider (Cline or BYO) - always shown. Used to switch between Cline and BYO providers
|
||||
// ┃ Configure API provider - always shown. Launches provider setup wizard
|
||||
// ┃ 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 menu (ShowAuthMenuNoArgs)
|
||||
// No args: Show uth wizard
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
case 1:
|
||||
// One arg: Provider ID only, prompt for API key
|
||||
return QuickAPISetup(args[0], "")
|
||||
case 2:
|
||||
// Two args: Provider ID and API key
|
||||
return QuickAPISetup(args[0], args[1])
|
||||
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("quick BYO API setup is currently stubbed - not yet implemented")
|
||||
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
|
||||
@@ -64,14 +111,28 @@ func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
if manager, err := createTaskManager(ctx); err == nil {
|
||||
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = getProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action, err := ShowAuthMenuWithStatus(isClineAuth, currentProvider, currentModel)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -82,6 +143,8 @@ func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
return HandleAPIProviderSetup(ctx)
|
||||
case AuthActionChangeClineModel:
|
||||
return HandleChangeClineModel(ctx)
|
||||
case AuthActionSelectOrganization:
|
||||
return HandleSelectOrganization(ctx)
|
||||
case AuthActionSelectProvider:
|
||||
return HandleSelectProvider(ctx)
|
||||
case AuthActionExit:
|
||||
@@ -92,7 +155,7 @@ func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// ShowAuthMenuWithStatus displays the main auth menu with Cline + provider status
|
||||
func ShowAuthMenuWithStatus(isClineAuthenticated bool, currentProvider, currentModel string) (AuthAction, error) {
|
||||
func ShowAuthMenuWithStatus(isClineAuthenticated bool, hasOrganizations bool, currentProvider, currentModel string) (AuthAction, error) {
|
||||
var action AuthAction
|
||||
var options []huh.Option[AuthAction]
|
||||
|
||||
@@ -100,34 +163,44 @@ func ShowAuthMenuWithStatus(isClineAuthenticated bool, currentProvider, currentM
|
||||
if isClineAuthenticated {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Change Cline model", AuthActionChangeClineModel),
|
||||
}
|
||||
|
||||
// Add organization selection if user has organizations
|
||||
if hasOrganizations {
|
||||
options = append(options, huh.NewOption("Select organization", AuthActionSelectOrganization))
|
||||
}
|
||||
|
||||
options = append(options,
|
||||
huh.NewOption("Sign out of Cline", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure API provider", AuthActionBYOSetup),
|
||||
huh.NewOption("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 API provider", AuthActionBYOSetup),
|
||||
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 = "Cline Account: \033[32m✓\033[0m Authenticated\n"
|
||||
title = fmt.Sprintf("Cline Account: %s Authenticated\n", renderer.Green("✓"))
|
||||
} else {
|
||||
title = "Cline Account: \033[31m✗\033[0m Not authenticated\n"
|
||||
title = fmt.Sprintf("Cline Account: %s Not authenticated\n", renderer.Red("✗"))
|
||||
}
|
||||
|
||||
// Show active provider and model if configured (regardless of Cline auth status)
|
||||
// ANSI color codes: Normal intensity = \033[22m, White = \033[37m, Reset = \033[0m
|
||||
if currentProvider != "" && currentModel != "" {
|
||||
title += fmt.Sprintf("Active Provider: \033[22m\033[37m%s\033[0m\nActive Model: \033[22m\033[37m%s\033[0m\n", currentProvider, currentModel)
|
||||
title += fmt.Sprintf("Active Provider: %s\nActive Model: %s\n",
|
||||
renderer.White(currentProvider),
|
||||
renderer.White(currentModel))
|
||||
}
|
||||
|
||||
// Always end with a huh?
|
||||
@@ -143,6 +216,11 @@ func ShowAuthMenuWithStatus(isClineAuthenticated bool, currentProvider, currentM
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -179,7 +257,7 @@ func HandleSelectProvider(ctx context.Context) error {
|
||||
|
||||
// Add each configured provider to the selection menu
|
||||
for _, provider := range availableProviders {
|
||||
providerName := getProviderDisplayName(provider)
|
||||
providerName := GetProviderDisplayName(provider)
|
||||
providerKey := fmt.Sprintf("provider_%d", provider)
|
||||
providerOptions = append(providerOptions, huh.NewOption(providerName, providerKey))
|
||||
providerMapping[providerKey] = provider
|
||||
@@ -190,11 +268,6 @@ func HandleSelectProvider(ctx context.Context) error {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
if len(providerOptions) == 1 {
|
||||
fmt.Println("Only one provider is configured. Configure another provider to switch between them.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
providerOptions = append(providerOptions, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
@@ -209,6 +282,10 @@ func HandleSelectProvider(ctx context.Context) error {
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -230,7 +307,12 @@ func HandleSelectProvider(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,240 @@
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// QuickAPISetup performs quick provider setup with provider ID and optional API key
|
||||
func QuickAPISetup(providerID, apiKey string) error {
|
||||
fmt.Println("Quick BYO API setup is currently stubbed - not yet implemented.")
|
||||
fmt.Printf("Requested provider: %s\n", providerID)
|
||||
if apiKey != "" {
|
||||
fmt.Println("Provided API key:", "<jk redacted>")
|
||||
"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 := createTaskManager(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
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
package auth
|
||||
@@ -64,8 +64,15 @@ func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
return fmt.Errorf("no usable Cline models found")
|
||||
}
|
||||
|
||||
// Apply the default model
|
||||
return applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo)
|
||||
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.
|
||||
@@ -116,8 +123,19 @@ func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, mo
|
||||
return UpdateProviderPartial(ctx, manager, provider, updates, true)
|
||||
}
|
||||
|
||||
// applyDefaultClineModel applies the default Cline model without model info.
|
||||
// This is a fallback when model fetching fails.
|
||||
func applyDefaultClineModel(ctx context.Context, manager *task.Manager, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
return applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -14,13 +14,33 @@ import (
|
||||
|
||||
// FetchOpenRouterModels fetches available OpenRouter models from Cline Core
|
||||
func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOpenRouterModels(ctx, &cline.EmptyRequest{})
|
||||
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) {
|
||||
@@ -100,9 +120,9 @@ func ConvertModelsMapToSlice(models map[string]interface{}) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map.
|
||||
// This allows OpenRouter and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} {
|
||||
// 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
|
||||
|
||||
@@ -18,14 +18,15 @@ type BYOProviderOption struct {
|
||||
func GetBYOProviderList() []BYOProviderOption {
|
||||
return []BYOProviderOption{
|
||||
{Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC},
|
||||
{Name: "OpenAI", Provider: cline.ApiProvider_OPENAI},
|
||||
{Name: "OpenAI Native", Provider: cline.ApiProvider_OPENAI_NATIVE},
|
||||
{Name: "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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +72,8 @@ func SupportsBYOModelFetching(provider cline.ApiProvider) bool {
|
||||
return true
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return true
|
||||
case cline.ApiProvider_OCA:
|
||||
return true
|
||||
}
|
||||
|
||||
return SupportsStaticModelList(provider)
|
||||
@@ -82,9 +85,9 @@ func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "e.g., claude-sonnet-4-5-20250929"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "e.g., gpt-5-2025-08-07"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "e.g., openai/gpt-oss-120b"
|
||||
case cline.ApiProvider_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:
|
||||
@@ -97,6 +100,8 @@ func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
|
||||
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"
|
||||
}
|
||||
@@ -127,8 +132,8 @@ func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig {
|
||||
}
|
||||
|
||||
// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama).
|
||||
// For OpenAI Native provider, also prompts for an optional base URL.
|
||||
func PromptForAPIKey(provider cline.ApiProvider) (string, error) {
|
||||
// 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)
|
||||
|
||||
@@ -149,11 +154,11 @@ func PromptForAPIKey(provider cline.ApiProvider) (string, error) {
|
||||
form := huh.NewForm(huh.NewGroup(apiKeyField))
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get API key: %w", err)
|
||||
return "", "", fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// For OpenAI Native provider, also prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI_NATIVE {
|
||||
// For OpenAI (Compatible) provider, prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI {
|
||||
var baseURL string
|
||||
baseURLForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
@@ -166,12 +171,11 @@ func PromptForAPIKey(provider cline.ApiProvider) (string, error) {
|
||||
)
|
||||
|
||||
if err := baseURLForm.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get base URL: %w", err)
|
||||
return "", "", fmt.Errorf("failed to get base URL: %w", err)
|
||||
}
|
||||
|
||||
// TODO - connect baseURL
|
||||
_ = baseURL
|
||||
return apiKey, baseURL, nil
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
return apiKey, "", nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
@@ -110,6 +111,8 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
cline.ApiProvider_GEMINI,
|
||||
cline.ApiProvider_OLLAMA,
|
||||
cline.ApiProvider_CEREBRAS,
|
||||
cline.ApiProvider_OCA,
|
||||
cline.ApiProvider_HICAP,
|
||||
}
|
||||
|
||||
// Check each provider to see if it's ready to use
|
||||
@@ -120,16 +123,23 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has an API key
|
||||
hasAPIKey := checkAPIKeyExists(r.apiConfig, provider)
|
||||
if !hasAPIKey {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has a model configured
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
if modelID == "" {
|
||||
continue
|
||||
|
||||
// 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
|
||||
@@ -145,7 +155,7 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
Mode: "Ready",
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
HasAPIKey: checkAPIKeyExists(r.apiConfig, provider),
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
seenProviders[provider] = true
|
||||
@@ -203,13 +213,15 @@ func extractProviderFromState(stateData map[string]interface{}, mode string) *Pr
|
||||
// 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) {
|
||||
normalizedStr := strings.ToLower(providerStr)
|
||||
|
||||
// Map string values to enum values
|
||||
switch providerStr {
|
||||
switch normalizedStr {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, true
|
||||
case "openai":
|
||||
case "openai", "openai-compatible": // internal name is 'openai', but this is actually the openai-compatible provider
|
||||
return cline.ApiProvider_OPENAI, true
|
||||
case "openai-native":
|
||||
case "openai-native": // This is the native, official Open AI provider
|
||||
return cline.ApiProvider_OPENAI_NATIVE, true
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, true
|
||||
@@ -225,6 +237,10 @@ func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
|
||||
return cline.ApiProvider_CEREBRAS, true
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, true
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, true
|
||||
case "hicap":
|
||||
return cline.ApiProvider_HICAP, true
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false
|
||||
}
|
||||
@@ -237,7 +253,7 @@ func GetProviderIDForEnum(provider cline.ApiProvider) string {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "openai"
|
||||
return "openai-compatible"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "openai-native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
@@ -254,6 +270,10 @@ func GetProviderIDForEnum(provider cline.ApiProvider) string {
|
||||
return "cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "cline"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "oca"
|
||||
case cline.ApiProvider_HICAP:
|
||||
return "hicap"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -306,15 +326,15 @@ func capitalizeMode(mode string) string {
|
||||
return strings.ToUpper(mode[:1]) + mode[1:]
|
||||
}
|
||||
|
||||
// getProviderDisplayName returns a user-friendly name for the provider
|
||||
func getProviderDisplayName(provider cline.ApiProvider) string {
|
||||
// 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"
|
||||
return "OpenAI Compatible"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "OpenAI Native"
|
||||
return "OpenAI (Official)"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "OpenRouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
@@ -329,6 +349,10 @@ func getProviderDisplayName(provider cline.ApiProvider) string {
|
||||
return "Cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "Cline (Official)"
|
||||
case cline.ApiProvider_OCA:
|
||||
return "Oracle Code Assist"
|
||||
case cline.ApiProvider_HICAP:
|
||||
return "Hicap"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -364,9 +388,9 @@ func FormatProviderList(result *ProviderListResult) string {
|
||||
isActive := activeProviderSet && display.Provider == activeProvider
|
||||
|
||||
if isActive {
|
||||
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", getProviderDisplayName(display.Provider)))
|
||||
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", GetProviderDisplayName(display.Provider)))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf(" • %s\n", getProviderDisplayName(display.Provider)))
|
||||
output.WriteString(fmt.Sprintf(" • %s\n", GetProviderDisplayName(display.Provider)))
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID))
|
||||
@@ -378,7 +402,7 @@ func FormatProviderList(result *ProviderListResult) string {
|
||||
} else {
|
||||
output.WriteString(" Base URL: (default)\n")
|
||||
}
|
||||
} else if display.Provider == cline.ApiProvider_CLINE {
|
||||
} else if display.Provider == cline.ApiProvider_CLINE || display.Provider == cline.ApiProvider_OCA {
|
||||
output.WriteString(" Status: Authenticated\n")
|
||||
} else {
|
||||
output.WriteString(" API Key: Configured\n")
|
||||
@@ -430,6 +454,12 @@ func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([
|
||||
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
|
||||
@@ -444,24 +474,26 @@ func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([
|
||||
{cline.ApiProvider_GEMINI, "geminiApiKey"},
|
||||
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
|
||||
{cline.ApiProvider_HICAP, "hicapApiKey"},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", getProviderDisplayName(providerCheck.provider), providerCheck.keyField)
|
||||
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))
|
||||
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))
|
||||
verboseLog("[DEBUG] - %s", GetProviderDisplayName(p))
|
||||
}
|
||||
|
||||
return configuredProviders, nil
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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)")
|
||||
@@ -46,6 +46,7 @@ func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, r
|
||||
// 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)
|
||||
@@ -68,6 +69,7 @@ func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiApiKey",
|
||||
BaseURLField: "openAiBaseUrl",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId",
|
||||
@@ -142,6 +144,25 @@ func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OCA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ocaApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOcaModelInfo",
|
||||
ActModeModelInfoField: "actModeOcaModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOcaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOcaModelId",
|
||||
}, nil
|
||||
case cline.ApiProvider_HICAP:
|
||||
return ProviderFields{
|
||||
APIKeyField: "hicapApiKey",
|
||||
PlanModeModelInfoField: "planModeHicapModelInfo",
|
||||
ActModeModelInfoField: "actModeHicapModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeHicapModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeHicapModelId",
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider)
|
||||
}
|
||||
@@ -150,9 +171,12 @@ func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
// 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)
|
||||
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.
|
||||
@@ -182,7 +206,7 @@ func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error
|
||||
// buildProviderFieldMask builds a list of camelCase field paths for the field mask.
|
||||
// When includeProviderEnums is true, the provider enum fields are included (for setting active provider).
|
||||
// When false, only the data fields are included (for configuring without activating).
|
||||
func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeProviderEnums bool) []string {
|
||||
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)
|
||||
@@ -199,6 +223,11 @@ func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeMo
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -245,6 +274,10 @@ func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, v
|
||||
apiConfig.CerebrasApiKey = value
|
||||
case "clineApiKey":
|
||||
apiConfig.ClineApiKey = value
|
||||
case "ocaApiKey":
|
||||
apiConfig.OcaApiKey = value
|
||||
case "hicapApiKey":
|
||||
apiConfig.HicapApiKey = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,11 +296,17 @@ func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldNa
|
||||
case "planModeAwsBedrockCustomModelBaseId":
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = value
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = value
|
||||
case "planModeOcaModelId":
|
||||
apiConfig.PlanModeOcaModelId = value
|
||||
apiConfig.ActModeOcaModelId = value
|
||||
case "planModeHicapModelId":
|
||||
apiConfig.PlanModeHicapModelId = value
|
||||
apiConfig.ActModeHicapModelId = value
|
||||
}
|
||||
}
|
||||
|
||||
// AddProviderPartial configures a new provider with all necessary fields using partial updates.
|
||||
func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, modelInfo interface{}) error {
|
||||
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 {
|
||||
@@ -282,6 +321,13 @@ func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cli
|
||||
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)
|
||||
@@ -301,7 +347,7 @@ func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cli
|
||||
|
||||
// Build field mask including all fields we're setting (without provider enums)
|
||||
includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil
|
||||
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, false)
|
||||
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, includeBaseURL, false)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
@@ -368,7 +414,7 @@ func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider
|
||||
}
|
||||
|
||||
// Build field mask for only the fields being updated
|
||||
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, setAsActive)
|
||||
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, false, setAsActive)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
@@ -421,6 +467,46 @@ func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider
|
||||
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
|
||||
@@ -434,6 +520,12 @@ type BedrockOptionalFields struct {
|
||||
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 {
|
||||
@@ -469,6 +561,20 @@ func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *B
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -507,3 +613,21 @@ func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+117
-19
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
@@ -20,11 +21,8 @@ type ProviderWizard struct {
|
||||
|
||||
// NewProviderWizard prepares a new provider configuration wizard
|
||||
func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) {
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure Cline Core instance: %w", err)
|
||||
}
|
||||
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
// 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)
|
||||
}
|
||||
@@ -43,7 +41,7 @@ func (pw *ProviderWizard) showMainMenu() (string, error) {
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Configure a new provider", "add"),
|
||||
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"),
|
||||
@@ -110,8 +108,13 @@ func (pw *ProviderWizard) handleAddProvider() error {
|
||||
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, err := PromptForAPIKey(provider)
|
||||
apiKey, baseURL, err := PromptForAPIKey(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
@@ -123,10 +126,14 @@ func (pw *ProviderWizard) handleAddProvider() error {
|
||||
}
|
||||
|
||||
// Step 5: Apply configuration using AddProviderPartial
|
||||
if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, modelInfo); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -153,10 +160,59 @@ func (pw *ProviderWizard) handleAddBedrockProvider() error {
|
||||
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)
|
||||
@@ -254,6 +310,15 @@ func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, api
|
||||
}
|
||||
// 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
|
||||
@@ -376,7 +441,7 @@ func (pw *ProviderWizard) handleChangeModel() error {
|
||||
options := make([]huh.Option[int], len(configurableProviders)+1)
|
||||
for i, providerDisplay := range configurableProviders {
|
||||
displayName := fmt.Sprintf("%s (current: %s)",
|
||||
getProviderDisplayName(providerDisplay.Provider),
|
||||
GetProviderDisplayName(providerDisplay.Provider),
|
||||
providerDisplay.ModelID)
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
@@ -402,7 +467,7 @@ func (pw *ProviderWizard) handleChangeModel() error {
|
||||
selectedProvider := configurableProviders[selectedIndex]
|
||||
provider := selectedProvider.Provider
|
||||
|
||||
fmt.Printf("\nChanging model for %s\n", getProviderDisplayName(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
|
||||
@@ -426,7 +491,7 @@ func (pw *ProviderWizard) handleChangeModel() error {
|
||||
|
||||
apiKey = getProviderAPIKeyFromState(apiConfig, provider)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key found for provider %s", getProviderDisplayName(provider))
|
||||
return fmt.Errorf("no API key found for provider %s", GetProviderDisplayName(provider))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +544,7 @@ func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cl
|
||||
// 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))
|
||||
return fmt.Errorf("no model configured for provider %s", GetProviderDisplayName(provider))
|
||||
}
|
||||
|
||||
// Get model info if available (for OpenRouter/Cline)
|
||||
@@ -500,7 +565,7 @@ func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cl
|
||||
return fmt.Errorf("failed to switch provider: %w", err)
|
||||
}
|
||||
|
||||
verboseLog("✓ Switched to %s\n", getProviderDisplayName(provider))
|
||||
verboseLog("✓ Switched to %s\n", GetProviderDisplayName(provider))
|
||||
verboseLog(" Using model: %s\n", modelID)
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
@@ -520,8 +585,17 @@ func getProviderModelIDFromState(stateData map[string]interface{}, provider clin
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
|
||||
// Return a sentinel non-empty string so upstream checks pass.
|
||||
return "OCA_AUTH_VERIFIED"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -602,7 +676,7 @@ func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
options := make([]huh.Option[int], len(removableProviders))
|
||||
for i, provider := range removableProviders {
|
||||
// Mark active provider
|
||||
displayName := getProviderDisplayName(provider.Provider)
|
||||
displayName := GetProviderDisplayName(provider.Provider)
|
||||
if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider {
|
||||
displayName += " (ACTIVE)"
|
||||
}
|
||||
@@ -626,7 +700,7 @@ func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
|
||||
// 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.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
|
||||
}
|
||||
@@ -636,7 +710,7 @@ func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
confirmForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(fmt.Sprintf("Are you sure you want to remove %s?", getProviderDisplayName(selectedProvider.Provider))).
|
||||
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),
|
||||
),
|
||||
@@ -651,12 +725,21 @@ func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 7: Clear the API key for the selected provider
|
||||
// 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))
|
||||
fmt.Printf("\n✓ %s removed successfully\n", GetProviderDisplayName(selectedProvider.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -664,3 +747,18 @@ func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
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,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 ""
|
||||
}
|
||||
@@ -123,7 +123,10 @@ func setCommand() *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.`,
|
||||
Long: `Set one or more global configuration variables using key=value format.
|
||||
|
||||
This command merges the provided settings with existing values, preserving
|
||||
unspecified fields. Only the fields you explicitly set will be updated.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
@@ -139,7 +142,7 @@ func setCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update settings
|
||||
// Update settings (server-side merge handles preserving existing values)
|
||||
return configManager.UpdateSettings(ctx, settings, secrets)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func renderAutoApprovalSettings(value interface{}, censor bool) error {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print other fields normally (enabled, maxRequests, enableNotifications, favorites)
|
||||
// Print other fields normally (enabled, enableNotifications, favorites)
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package display
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -13,25 +16,71 @@ type MarkdownRenderer struct {
|
||||
width int
|
||||
}
|
||||
|
||||
// Custom style JSON that removes margins while keeping all other auto style features
|
||||
// This is based on the "auto" style but with document and code_block margins set to 0
|
||||
const noMarginAutoStyleDark = `{
|
||||
"document": {
|
||||
"block_prefix": "\n",
|
||||
"block_suffix": "\n",
|
||||
"color": "252",
|
||||
"margin": 0
|
||||
},
|
||||
"code_block": {
|
||||
"margin": 0
|
||||
}
|
||||
}`
|
||||
// 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("auto"), // Load full auto style first
|
||||
glamour.WithStylesFromJSONBytes([]byte(noMarginAutoStyleDark)), // Then override just margins
|
||||
glamour.WithWordWrap(0), // 0 = no wrapping, let terminal handle it
|
||||
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 {
|
||||
@@ -44,32 +93,40 @@ func NewMarkdownRenderer() (*MarkdownRenderer, error) {
|
||||
}, 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("auto"), // Load full auto style first
|
||||
glamour.WithStylesFromJSONBytes([]byte(noMarginAutoStyleDark)), // Then override just margins
|
||||
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
|
||||
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, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width == 0 {
|
||||
width = 120 // Fallback width
|
||||
}
|
||||
width := terminalWidthOr(120)
|
||||
return NewMarkdownRendererWithWidth(width)
|
||||
}
|
||||
|
||||
|
||||
+124
-41
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -13,6 +15,16 @@ type Renderer struct {
|
||||
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(outputFormat string) *Renderer {
|
||||
@@ -20,12 +32,24 @@ func NewRenderer(outputFormat string) *Renderer {
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
return &Renderer{
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
@@ -39,26 +63,9 @@ func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
}
|
||||
|
||||
if newline {
|
||||
fmt.Printf("%s: %s\n", prefix, clean)
|
||||
output.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
fmt.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Renderer) RenderCheckpointMessage(timestamp, prefix string, id int64) error {
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderCommand(command string, isExecuting bool) error {
|
||||
if isExecuting {
|
||||
r.typewriter.PrintMessageLine("EXEC", command)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine("CMD", command)
|
||||
output.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -75,26 +82,40 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
|
||||
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
output.Print(rendered)
|
||||
} else {
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// fmt.Printf("\n%s\n", rendered)
|
||||
// output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -109,6 +130,13 @@ func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Renderer) RenderTaskCancelled() error {
|
||||
markdown := "## Task cancelled"
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTaskList displays task history with improved formatting
|
||||
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
const maxTasks = 20
|
||||
@@ -122,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)
|
||||
@@ -152,11 +180,11 @@ func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -201,21 +229,76 @@ func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
|
||||
|
||||
// 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
|
||||
// Respects output format - skips rendering in plain mode or non-TTY contexts
|
||||
func (r *Renderer) RenderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering in plain mode
|
||||
if r.outputFormat == "plain" {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
@@ -34,15 +35,15 @@ func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, s
|
||||
msg: msg,
|
||||
toolParser: NewToolResultParser(mdRenderer),
|
||||
}
|
||||
|
||||
// Render rich header immediately when creating segment (if in rich mode)
|
||||
if shouldMarkdown && outputFormat != "plain" {
|
||||
|
||||
// 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)
|
||||
fmt.Println()
|
||||
fmt.Print(rendered)
|
||||
output.Println("")
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
@@ -112,8 +113,8 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
} else if ss.sayType == string(types.SayTypeCommand) {
|
||||
// Command output
|
||||
bodyContent = "```shell\n" + currentBuffer + "\n```"
|
||||
// Render markdown
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
// 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
|
||||
@@ -121,7 +122,7 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
}
|
||||
} else {
|
||||
// For other types (reasoning, text, etc.), render markdown as-is
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
|
||||
rendered, err := ss.mdRenderer.Render(currentBuffer)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
@@ -136,10 +137,10 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
// Print the body content
|
||||
if bodyContent != "" {
|
||||
if !strings.HasSuffix(bodyContent, "\n") {
|
||||
fmt.Print(bodyContent)
|
||||
fmt.Println()
|
||||
output.Print(bodyContent)
|
||||
output.Println("")
|
||||
} else {
|
||||
fmt.Print(bodyContent)
|
||||
output.Print(bodyContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -339,9 +339,10 @@ func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) strin
|
||||
return fmt.Sprintf("%s %s\n", symbol, status)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown if not in plain mode
|
||||
// renderMarkdown renders markdown if not in plain mode and in a TTY
|
||||
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
|
||||
if tr.outputFormat == "plain" {
|
||||
// Skip markdown rendering if plain mode or not in TTY
|
||||
if tr.outputFormat == "plain" || !isTTY() {
|
||||
return markdown
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -42,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)
|
||||
@@ -61,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
|
||||
@@ -95,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
|
||||
}
|
||||
|
||||
@@ -114,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)
|
||||
@@ -133,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
|
||||
@@ -167,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
|
||||
}
|
||||
|
||||
@@ -212,7 +243,9 @@ 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()
|
||||
@@ -227,16 +260,39 @@ func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
||||
}
|
||||
|
||||
// Create timestamped log file
|
||||
timestamp := time.Now().Format("2006-01-02-15-04-05")
|
||||
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
|
||||
logFilePath := path.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -248,7 +304,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
}
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
@@ -262,7 +320,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
@@ -270,11 +330,15 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
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) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", 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)
|
||||
@@ -284,7 +348,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,40 +364,101 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
}
|
||||
|
||||
// Get paths relative to the cline binary location
|
||||
// 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)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
|
||||
// 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)
|
||||
nodePath := path.Join(binDir, "node")
|
||||
clineCorePath := path.Join(installDir, "cline-core.js")
|
||||
|
||||
// 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)
|
||||
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
|
||||
args := []string{clineCorePath,
|
||||
// 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: %s %v\n", nodePath, args)
|
||||
fmt.Printf("DEBUG: Working directory: %s\n", installDir)
|
||||
fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath)
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Using system node\n")
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, args...)
|
||||
cmd := exec.Command("node", args...)
|
||||
|
||||
// Set working directory to installation root
|
||||
cmd.Dir = installDir
|
||||
cmd.Dir = finalInstallDir
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
@@ -342,22 +469,33 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
// Set environment variables with NODE_PATH for node_modules
|
||||
// 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", path.Join(installDir, "node_modules")),
|
||||
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)
|
||||
|
||||
@@ -72,20 +89,25 @@ func EnsureDefaultInstance(ctx context.Context) error {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
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 default instance, start a new one
|
||||
instance, err := Clients.StartNewInstance(ctx)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -50,8 +52,6 @@ func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
@@ -69,22 +69,25 @@ func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Use ToolRenderer for unified rendering
|
||||
header := dc.ToolRenderer.GenerateAskFollowupHeader()
|
||||
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
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
|
||||
}
|
||||
@@ -96,7 +99,7 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
|
||||
// Just render the body content
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
if body != "" {
|
||||
fmt.Print(body)
|
||||
output.Print(body)
|
||||
}
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
@@ -109,17 +112,25 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(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) error {
|
||||
if msg.Text == "" {
|
||||
@@ -130,9 +141,10 @@ func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
output.Print(rendered)
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -166,42 +178,87 @@ func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
fmt.Print(output)
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
// Non-streaming mode: render full approval (header + preview)
|
||||
rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
h.showApprovalHint(dc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Try to parse as ClineError for better error display
|
||||
clineErr, _ := clerror.ParseClineError(msg.Text)
|
||||
if clineErr != nil {
|
||||
if dc.SystemRenderer != nil {
|
||||
// Render the error with system renderer
|
||||
switch clineErr.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
dc.SystemRenderer.RenderBalanceError(clineErr)
|
||||
case clerror.ErrorTypeAuth:
|
||||
dc.SystemRenderer.RenderAuthError(clineErr)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
dc.SystemRenderer.RenderRateLimitError(clineErr)
|
||||
default:
|
||||
dc.SystemRenderer.RenderAPIError(clineErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Fallback: render with basic renderer using parsed message
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", clineErr.Message), true)
|
||||
}
|
||||
// Last resort: display raw text if parsing completely failed
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Resuming interrupted task.", true)
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Resuming completed task.", true)
|
||||
// Don't render - this is metadata only, user already knows they're resuming
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
details := make(map[string]string)
|
||||
if msg.Text != "" {
|
||||
details["details"] = msg.Text
|
||||
}
|
||||
dc.SystemRenderer.RenderError(
|
||||
"critical",
|
||||
"Mistake Limit Reached",
|
||||
"Cline has made too many consecutive mistakes and needs your guidance to proceed.",
|
||||
details,
|
||||
)
|
||||
fmt.Printf("\n**Approval required to continue.**\n")
|
||||
return nil
|
||||
}
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
|
||||
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
|
||||
@@ -230,8 +287,11 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage("MCP",
|
||||
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
|
||||
|
||||
@@ -22,9 +22,10 @@ type MessageHandler interface {
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
ToolRenderer *display.ToolRenderer
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
ToolRenderer *display.ToolRenderer
|
||||
SystemRenderer *display.SystemMessageRenderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +52,8 @@ func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return h.handleUserFeedbackDiff(msg, dc)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc)
|
||||
case string(types.SayTypeErrorRetry):
|
||||
return h.handleErrorRetry(msg, dc)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
@@ -109,6 +113,14 @@ func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayCon
|
||||
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" {
|
||||
@@ -134,6 +146,24 @@ func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayCon
|
||||
return dc.Renderer.RenderAPI("processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// renderClineError renders a ClineError with appropriate formatting based on type
|
||||
func (h *SayHandler) renderClineError(err *clerror.ClineError, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer == nil {
|
||||
return dc.Renderer.RenderMessage("ERROR", err.Message, true)
|
||||
}
|
||||
|
||||
switch err.GetErrorType() {
|
||||
case clerror.ErrorTypeBalance:
|
||||
return dc.SystemRenderer.RenderBalanceError(err)
|
||||
case clerror.ErrorTypeAuth:
|
||||
return dc.SystemRenderer.RenderAuthError(err)
|
||||
case clerror.ErrorTypeRateLimit:
|
||||
return dc.SystemRenderer.RenderRateLimitError(err)
|
||||
default:
|
||||
return dc.SystemRenderer.RenderAPIError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
@@ -150,8 +180,8 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
if dc.MessageIndex == 0 {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
fmt.Printf("\n")
|
||||
output.Printf("%s", rendered)
|
||||
output.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -160,12 +190,12 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
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)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -180,12 +210,12 @@ func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
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)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -201,12 +231,12 @@ func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *Display
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
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)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -230,7 +260,7 @@ func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayCont
|
||||
if msg.Text != "" {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
output.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
|
||||
@@ -256,6 +286,37 @@ func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayCon
|
||||
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
|
||||
}
|
||||
|
||||
// handleErrorRetry handles error retry status messages
|
||||
func (h *SayHandler) handleErrorRetry(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse retry info from message text
|
||||
type ErrorRetryInfo struct {
|
||||
Attempt int `json:"attempt"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
DelaySeconds int `json:"delaySeconds"`
|
||||
Failed bool `json:"failed"`
|
||||
}
|
||||
|
||||
var retryInfo ErrorRetryInfo
|
||||
if err := json.Unmarshal([]byte(msg.Text), &retryInfo); err != nil {
|
||||
// Fallback to simple message if parsing fails
|
||||
return dc.Renderer.RenderMessage("API INFO", "Auto-retry in progress", true)
|
||||
}
|
||||
|
||||
if retryInfo.Failed {
|
||||
// Retry failed after max attempts
|
||||
message := fmt.Sprintf("Auto-retry failed after %d attempts. Manual intervention required.", retryInfo.MaxAttempts)
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning("Auto-Retry Failed", message)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", message, true)
|
||||
}
|
||||
|
||||
// Retry in progress
|
||||
message := fmt.Sprintf("Attempt %d/%d - Retrying in %d seconds...",
|
||||
retryInfo.Attempt, retryInfo.MaxAttempts, retryInfo.DelaySeconds)
|
||||
return dc.Renderer.RenderMessage("API INFO", message, true)
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
@@ -263,8 +324,8 @@ func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -276,8 +337,8 @@ func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayCon
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -289,8 +350,8 @@ func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -392,27 +453,49 @@ func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderWarning(
|
||||
"Diff Edit Failure",
|
||||
"The model used search patterns that don't match anything in the file. Retrying...",
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// This message includes api metrics of deleted messages, which we do not log
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Checkpoint restored", true)
|
||||
// Don't render - this is internal metadata (aggregated API metrics from deleted checkpoint messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.SystemRenderer != nil {
|
||||
return dc.SystemRenderer.RenderInfo(
|
||||
"Access Denied",
|
||||
fmt.Sprintf("Cline tried to access `%s` which is blocked by the .clineignore file.", msg.Text),
|
||||
)
|
||||
}
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderCheckpointMessage(timestamp, "GEN INFO", msg.Timestamp)
|
||||
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) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Loading MCP documentation", true)
|
||||
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
|
||||
@@ -428,7 +511,7 @@ func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayCont
|
||||
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+170
-62
@@ -11,11 +11,53 @@ import (
|
||||
|
||||
"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",
|
||||
@@ -25,7 +67,7 @@ func NewInstanceCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newInstanceListCommand())
|
||||
cmd.AddCommand(newInstanceUseCommand())
|
||||
cmd.AddCommand(newInstanceDefaultCommand())
|
||||
cmd.AddCommand(newInstanceNewCommand())
|
||||
cmd.AddCommand(newInstanceKillCommand())
|
||||
|
||||
@@ -33,7 +75,7 @@ func NewInstanceCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newInstanceKillCommand() *cobra.Command {
|
||||
var killAll bool
|
||||
var killAllCLI bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "kill <address>",
|
||||
@@ -41,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
|
||||
},
|
||||
@@ -57,20 +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 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 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 {
|
||||
@@ -82,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)
|
||||
|
||||
@@ -97,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,11 +298,12 @@ func newInstanceListCommand() *cobra.Command {
|
||||
|
||||
// Build instance data
|
||||
type instanceRow struct {
|
||||
address string
|
||||
status string
|
||||
version string
|
||||
lastSeen string
|
||||
pid string
|
||||
address string
|
||||
status string
|
||||
version string
|
||||
lastSeen string
|
||||
pid string
|
||||
platform string
|
||||
isDefault string
|
||||
}
|
||||
|
||||
@@ -235,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)
|
||||
@@ -247,14 +333,20 @@ func newInstanceListCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get platform from host bridge
|
||||
if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil {
|
||||
platform = detectedPlatform
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, instanceRow{
|
||||
address: instance.Address,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
pid: pid,
|
||||
address: instance.Address,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
pid: pid,
|
||||
platform: platform,
|
||||
isDefault: isDefault,
|
||||
})
|
||||
}
|
||||
@@ -263,15 +355,16 @@ func newInstanceListCommand() *cobra.Command {
|
||||
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\tDEFAULT")
|
||||
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\n",
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -280,36 +373,37 @@ func newInstanceListCommand() *cobra.Command {
|
||||
} else {
|
||||
// Use markdown table for rich output
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **DEFAULT** |\n")
|
||||
markdown.WriteString("|---------|--------|---------|-----------|-----|---------|")
|
||||
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 |",
|
||||
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s | %s |",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.platform,
|
||||
row.isDefault,
|
||||
))
|
||||
}
|
||||
|
||||
// Render the markdown table with terminal width for nice table layout
|
||||
renderer, err := display.NewMarkdownRendererForTerminal()
|
||||
mdRenderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain table if markdown renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
rendered, err := renderer.Render(markdown.String())
|
||||
rendered, err := mdRenderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
// Post-process to colorize status values
|
||||
rendered = strings.ReplaceAll(rendered, "SERVING", "\033[32mSERVING\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "✓", "\033[32m✓\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", "\033[31mNOT_SERVING\033[0m") // Red
|
||||
rendered = strings.ReplaceAll(rendered, "UNKNOWN", "\033[33mUNKNOWN\033[0m") // Yellow
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -324,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),
|
||||
@@ -360,6 +454,8 @@ func newInstanceUseCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newInstanceNewCommand() *cobra.Command {
|
||||
var setDefault bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "new",
|
||||
Aliases: []string{"n"},
|
||||
@@ -384,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 (oldest first)
|
||||
sort.Slice(logs, func(i, j int) bool {
|
||||
return logs[i].created.Before(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)
|
||||
}
|
||||
|
||||
+243
-181
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -9,20 +10,23 @@ import (
|
||||
"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
|
||||
Workspaces []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Images []string
|
||||
Files []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
@@ -34,13 +38,12 @@ func NewTaskCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskOneshotCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(NewTaskSendCommand())
|
||||
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
|
||||
@@ -95,13 +98,12 @@ func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -113,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 {
|
||||
@@ -134,7 +142,9 @@ 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
|
||||
@@ -146,12 +156,14 @@ func newTaskNewCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
|
||||
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)
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -159,87 +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().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 newTaskOneshotCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
address string
|
||||
settings []string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "oneshot <prompt>",
|
||||
Aliases: []string{"o"},
|
||||
Short: "Create a task in yolo+plan mode and view until completion",
|
||||
Long: `Creates a new task in yolo mode (non-interactive) and plan mode, then streams the conversation until completion.`,
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Get prompt from args/stdin
|
||||
prompt, err := getContentFromStdinAndArgs(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required: provide as argument or pipe via stdin")
|
||||
}
|
||||
|
||||
// Ensure task manager
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode to plan
|
||||
if err := taskManager.SetMode(ctx, "plan", nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set plan mode: %w", err)
|
||||
}
|
||||
fmt.Println("Mode set to: plan")
|
||||
|
||||
// Inject yolo mode into settings
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
|
||||
// Create task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created in yolo+plan mode (ID: %s)\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Follow until completion
|
||||
return taskManager.FollowConversationUntilCompletion(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s model=claude)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskCancelCommand() *cobra.Command {
|
||||
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()
|
||||
|
||||
@@ -251,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
|
||||
},
|
||||
@@ -261,13 +208,15 @@ func newTaskCancelCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewTaskSendCommand() *cobra.Command {
|
||||
func newTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
address string
|
||||
mode string
|
||||
approve string
|
||||
approve bool
|
||||
deny bool
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -279,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
|
||||
@@ -302,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 != "" {
|
||||
@@ -320,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")
|
||||
@@ -335,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. Interactive input is enabled by default.`,
|
||||
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()
|
||||
@@ -356,6 +346,18 @@ func newTaskFollowCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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, taskManager.GetCurrentInstance(), true)
|
||||
},
|
||||
}
|
||||
@@ -367,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()
|
||||
@@ -387,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"},
|
||||
@@ -414,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()
|
||||
@@ -451,11 +450,74 @@ 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
|
||||
}
|
||||
|
||||
@@ -530,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,31 +619,16 @@ func CleanupTaskManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Always start a fresh new instance for the root command
|
||||
// This ensures users get a clean slate every time they run `cline`
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Started instance at %s\n", instance.Address)
|
||||
|
||||
// Set up cleanup on exit - kill the instance when this function returns
|
||||
defer func() {
|
||||
fmt.Println("\nCleaning up instance...")
|
||||
registry := global.Clients.GetRegistry()
|
||||
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, instance.Address); err != nil {
|
||||
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize task manager with the new instance
|
||||
if err := ensureTaskManager(ctx, instance.Address); err != nil {
|
||||
// Initialize task manager with the provided instance address
|
||||
if err := ensureTaskManager(ctx, opts.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -592,7 +642,9 @@ func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) e
|
||||
if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", opts.Mode)
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Mode set to: %s\n", opts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
@@ -601,13 +653,23 @@ func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) e
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Workspaces, opts.Settings)
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
|
||||
}
|
||||
|
||||
// Immediately follow the conversation in interactive mode
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
// 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)
|
||||
}
|
||||
+401
-185
@@ -2,34 +2,49 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
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
|
||||
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),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +59,9 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
ih.isRunning = false
|
||||
ih.mu.Unlock()
|
||||
ih.pollTicker.Stop()
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
@@ -55,7 +73,7 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
output.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -63,24 +81,18 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
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 {
|
||||
fmt.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
output.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -94,12 +106,12 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
}
|
||||
|
||||
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
|
||||
fmt.Printf("\nError sending approval: %v\n", err)
|
||||
output.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
output.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
@@ -108,213 +120,370 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
}
|
||||
|
||||
// Check if we can send a regular message
|
||||
sendDisabled, err := ih.manager.CheckSendDisabled(ctx)
|
||||
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 {
|
||||
fmt.Printf("\nDebug: CheckSendDisabled error: %v\n", err)
|
||||
output.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If send is enabled (not disabled), show prompt
|
||||
if !sendDisabled {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
// If we reach here, we can send a message
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
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
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
if shouldSend {
|
||||
// Check for mode switch commands first
|
||||
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
|
||||
if isModeSwitch {
|
||||
// Switch mode
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
fmt.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\nSwitched to %s mode\n", newMode)
|
||||
|
||||
// If there's remaining message, use it as the new message to send
|
||||
if remainingMessage != "" {
|
||||
message = remainingMessage
|
||||
} else {
|
||||
// No message to send, just mode switch
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
if handled := ih.handleSpecialCommand(ctx, message); handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the message
|
||||
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
|
||||
fmt.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
// Handle special commands
|
||||
if handled := ih.handleSpecialCommand(ctx, message); handled {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
// 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) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
var message string
|
||||
|
||||
// Get current mode and format title with color
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
// ANSI color codes
|
||||
yellow := "\033[33m" // Yellow for plan mode
|
||||
blue := "\033[34m" // Blue for act mode
|
||||
indigo := "\033[38;5;99m" // Indigo (huh default title color) - approximation of #7571F9
|
||||
bold := "\033[1m" // Bold
|
||||
reset := "\033[0m" // Reset
|
||||
|
||||
var coloredMode string
|
||||
if currentMode == "plan" {
|
||||
coloredMode = fmt.Sprintf("%s[plan mode]%s", yellow, reset)
|
||||
} else {
|
||||
coloredMode = fmt.Sprintf("%s[act mode]%s", blue, reset)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%s %s%sCline is ready for your message%s", coloredMode, bold, indigo, reset)
|
||||
|
||||
// Create multiline text area form using huh
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title(title).
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&message),
|
||||
),
|
||||
model := output.NewInputModel(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message...",
|
||||
"/plan or /act to switch modes\nctrl+e to open editor",
|
||||
currentMode,
|
||||
)
|
||||
|
||||
// Run the form
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
message = strings.TrimSpace(message)
|
||||
|
||||
// If empty, user just wants to keep watching
|
||||
if message == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return message, true, nil
|
||||
return ih.runInputProgram(ctx, model)
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
// Returns (approved, message, error)
|
||||
// Note: The approval details are already shown by segment streamer / state stream
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
// Show selection menu (approval details already displayed by other handlers)
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Let Cline use this tool?").
|
||||
Options(
|
||||
huh.NewOption("Yes", "yes"),
|
||||
huh.NewOption("Yes, with feedback", "yes_feedback"),
|
||||
huh.NewOption("No", "no"),
|
||||
huh.NewOption("No, with feedback", "no_feedback"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
// 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(),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
message, shouldSend, err := ih.runInputProgram(ctx, model)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
// Check if feedback is needed
|
||||
needsFeedback := choice == "yes_feedback" || choice == "no_feedback"
|
||||
approved := choice == "yes" || choice == "yes_feedback"
|
||||
|
||||
var feedback string
|
||||
if needsFeedback {
|
||||
// Show multiline text area for feedback
|
||||
feedbackForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Your feedback").
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&feedback),
|
||||
),
|
||||
)
|
||||
|
||||
err := feedbackForm.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
feedback = strings.TrimSpace(feedback)
|
||||
if !shouldSend {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
return approved, feedback, 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
|
||||
// Returns: (newMode, remainingMessage, isModeSwitch)
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
// Extract remaining message after /plan
|
||||
remaining := strings.TrimSpace(trimmed[5:]) // Remove "/plan"
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
// Extract remaining message after /act
|
||||
remaining := strings.TrimSpace(trimmed[4:]) // Remove "/act"
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
@@ -325,16 +494,15 @@ func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(message)) {
|
||||
case "/cancel":
|
||||
fmt.Println("\nCancelling task...")
|
||||
ih.manager.GetRenderer().RenderTaskCancelled()
|
||||
if err := ih.manager.CancelTask(ctx); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
output.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Task cancelled successfully")
|
||||
output.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
fmt.Println("\nExiting follow mode...")
|
||||
// This will be handled by context cancellation
|
||||
output.Println("\nExiting follow mode...")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -348,6 +516,9 @@ func (ih *InputHandler) Stop() {
|
||||
if ih.pollTicker != nil {
|
||||
ih.pollTicker.Stop()
|
||||
}
|
||||
if ih.program != nil && ih.programRunning {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.isRunning = false
|
||||
}
|
||||
|
||||
@@ -357,3 +528,48 @@ func (ih *InputHandler) IsRunning() bool {
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
+160
-151
@@ -3,6 +3,7 @@ package task
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -18,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
|
||||
@@ -26,6 +33,7 @@ type Manager struct {
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
toolRenderer *display.ToolRenderer
|
||||
systemRenderer *display.SystemMessageRenderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
isStreamingMode bool
|
||||
@@ -38,6 +46,7 @@ func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
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
|
||||
@@ -51,6 +60,7 @@ func NewManager(client *client.ClineClient) *Manager {
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
toolRenderer: toolRenderer,
|
||||
systemRenderer: systemRenderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
currentMode: "plan", // Default mode
|
||||
@@ -116,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, settingsFlags []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()
|
||||
|
||||
@@ -128,9 +138,6 @@ 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)
|
||||
}
|
||||
@@ -240,21 +247,32 @@ func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int
|
||||
return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID)
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// 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) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
func (m *Manager) CheckSendEnabled(ctx context.Context) error {
|
||||
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 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
|
||||
@@ -264,7 +282,6 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
errorTypes := []string{
|
||||
string(types.AskTypeAPIReqFailed), // "api_req_failed"
|
||||
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
|
||||
string(types.AskTypeAutoApprovalMaxReached), // "auto_approval_max_req_reached"
|
||||
}
|
||||
|
||||
isError := false
|
||||
@@ -284,15 +301,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
|
||||
@@ -300,14 +327,14 @@ 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
|
||||
@@ -578,65 +605,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()
|
||||
|
||||
@@ -675,17 +661,17 @@ func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string
|
||||
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 {
|
||||
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()
|
||||
@@ -721,21 +707,32 @@ func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sigChan:
|
||||
// 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
|
||||
fmt.Println("\nCancelling task...")
|
||||
if err := m.CancelTask(context.Background()); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
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
|
||||
}
|
||||
// Don't cancel main context - stay in follow mode
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -761,7 +758,7 @@ func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
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)
|
||||
@@ -887,9 +884,7 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat
|
||||
// Display valid messages, exit as soon as we hit a non-valid message
|
||||
if shouldDisplay {
|
||||
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@@ -935,59 +930,52 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCommand):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
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) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
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) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
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) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
@@ -996,10 +984,9 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
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
|
||||
@@ -1009,36 +996,25 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
case msg.Ask == string(types.AskTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Ask == string(types.AskTypePlanModeRespond):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
// In streaming mode, partial stream handles this message
|
||||
// State stream should skip to avoid duplication
|
||||
if m.isStreamingMode {
|
||||
// Skip - partial stream already handled this
|
||||
} else {
|
||||
// Non-streaming mode: render normally when message is complete
|
||||
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
// 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 !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
}
|
||||
@@ -1097,15 +1073,12 @@ func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *S
|
||||
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))
|
||||
|
||||
// Lock output to prevent race with input forms
|
||||
coordinator.WithOutputLock(func() {
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
})
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1132,6 +1105,7 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
ToolRenderer: m.toolRenderer,
|
||||
SystemRenderer: m.systemRenderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
@@ -1178,7 +1152,6 @@ func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error)
|
||||
totalMessages := len(messages)
|
||||
startIndex := 0
|
||||
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
@@ -1198,8 +1171,6 @@ func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
@@ -1265,6 +1236,44 @@ func (m *Manager) updateMode(stateJson string) {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UpdateTaskAutoApprovalAction enables a specific auto-approval action for the current task
|
||||
func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey string) error {
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
|
||||
settings := &cline.Settings{
|
||||
AutoApprovalSettings: &cline.AutoApprovalSettings{
|
||||
Actions: &cline.AutoApprovalActions{},
|
||||
},
|
||||
}
|
||||
|
||||
// Set the specific action to true based on actionKey
|
||||
truePtr := boolPtr(true)
|
||||
|
||||
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
|
||||
|
||||
@@ -180,8 +180,6 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
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":
|
||||
@@ -218,8 +216,6 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
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":
|
||||
@@ -314,6 +310,12 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
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 {
|
||||
@@ -410,24 +412,12 @@ func setNestedField(settings *cline.Settings, parentField string, childFields ma
|
||||
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
|
||||
settings.EnableNotifications = boolPtr(val)
|
||||
case "actions":
|
||||
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
|
||||
default:
|
||||
@@ -458,21 +448,21 @@ func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string
|
||||
|
||||
switch key {
|
||||
case "read_files":
|
||||
actions.ReadFiles = val
|
||||
actions.ReadFiles = boolPtr(val)
|
||||
case "read_files_externally":
|
||||
actions.ReadFilesExternally = val
|
||||
actions.ReadFilesExternally = boolPtr(val)
|
||||
case "edit_files":
|
||||
actions.EditFiles = val
|
||||
actions.EditFiles = boolPtr(val)
|
||||
case "edit_files_externally":
|
||||
actions.EditFilesExternally = val
|
||||
actions.EditFilesExternally = boolPtr(val)
|
||||
case "execute_safe_commands":
|
||||
actions.ExecuteSafeCommands = val
|
||||
actions.ExecuteSafeCommands = boolPtr(val)
|
||||
case "execute_all_commands":
|
||||
actions.ExecuteAllCommands = val
|
||||
actions.ExecuteAllCommands = boolPtr(val)
|
||||
case "use_browser":
|
||||
actions.UseBrowser = val
|
||||
actions.UseBrowser = boolPtr(val)
|
||||
case "use_mcp":
|
||||
actions.UseMcp = val
|
||||
actions.UseMcp = boolPtr(val)
|
||||
default:
|
||||
return fmt.Errorf("unsupported auto_approval_actions field '%s'", key)
|
||||
}
|
||||
@@ -666,6 +656,8 @@ func parseApiProvider(value string) (cline.ApiProvider, error) {
|
||||
return cline.ApiProvider_DIFY, nil
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, nil
|
||||
case "minimax":
|
||||
return cline.ApiProvider_MINIMAX, nil
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value)
|
||||
}
|
||||
@@ -740,14 +732,14 @@ func setSecretField(secrets *cline.Secrets, key, value string) error {
|
||||
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)
|
||||
case "hicap_api_key":
|
||||
secrets.HicapApiKey = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported secret field '%s'", key)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ type StreamCoordinator struct {
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
inputAllowed bool // Whether user input is currently allowed
|
||||
mu sync.RWMutex // Protects inputAllowed
|
||||
outputMu sync.Mutex // Protects terminal output (prevents interleaving with input forms)
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
@@ -59,23 +58,3 @@ func (sc *StreamCoordinator) IsInputAllowed() bool {
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.inputAllowed
|
||||
}
|
||||
|
||||
// LockOutput locks the output mutex to prevent interleaved terminal output
|
||||
// Should be called before displaying input forms
|
||||
func (sc *StreamCoordinator) LockOutput() {
|
||||
sc.outputMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockOutput unlocks the output mutex
|
||||
// Should be called after input forms are dismissed
|
||||
func (sc *StreamCoordinator) UnlockOutput() {
|
||||
sc.outputMu.Unlock()
|
||||
}
|
||||
|
||||
// WithOutputLock executes a function while holding the output lock
|
||||
// This is a convenience method for wrapping output operations
|
||||
func (sc *StreamCoordinator) WithOutputLock(fn func()) {
|
||||
sc.outputMu.Lock()
|
||||
defer sc.outputMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,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"`
|
||||
}
|
||||
@@ -37,17 +37,16 @@ const (
|
||||
type AskType string
|
||||
|
||||
const (
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeAutoApprovalMaxReached AskType = "auto_approval_max_req_reached"
|
||||
AskTypeFollowup AskType = "followup"
|
||||
AskTypePlanModeRespond AskType = "plan_mode_respond"
|
||||
AskTypeCommand AskType = "command"
|
||||
AskTypeCommandOutput AskType = "command_output"
|
||||
AskTypeCompletionResult AskType = "completion_result"
|
||||
AskTypeTool AskType = "tool"
|
||||
AskTypeAPIReqFailed AskType = "api_req_failed"
|
||||
AskTypeResumeTask AskType = "resume_task"
|
||||
AskTypeResumeCompletedTask AskType = "resume_completed_task"
|
||||
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
|
||||
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
|
||||
AskTypeUseMcpServer AskType = "use_mcp_server"
|
||||
AskTypeNewTask AskType = "new_task"
|
||||
@@ -69,6 +68,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"
|
||||
@@ -246,8 +246,6 @@ func convertProtoAskType(askType cline.ClineAsk) string {
|
||||
return string(AskTypeResumeCompletedTask)
|
||||
case cline.ClineAsk_MISTAKE_LIMIT_REACHED:
|
||||
return string(AskTypeMistakeLimitReached)
|
||||
case cline.ClineAsk_AUTO_APPROVAL_MAX_REQ_REACHED:
|
||||
return string(AskTypeAutoApprovalMaxReached)
|
||||
case cline.ClineAsk_BROWSER_ACTION_LAUNCH:
|
||||
return string(AskTypeBrowserActionLaunch)
|
||||
case cline.ClineAsk_USE_MCP_SERVER:
|
||||
@@ -286,6 +284,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:
|
||||
|
||||
@@ -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, "cli-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
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@@ -124,6 +126,16 @@ func NormalizeAddressForGRPC(address string) (string, error) {
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// GetNodeVersion returns the current Node.js version, or "unknown" if unable to detect
|
||||
func GetNodeVersion() string {
|
||||
cmd := exec.Command("node", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// RetryOperation performs an operation with retry logic
|
||||
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
|
||||
var lastErr error
|
||||
@@ -155,5 +167,19 @@ func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation f
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
|
||||
return fmt.Errorf(`operation failed to after %d attempts: %w
|
||||
|
||||
This is usually caused by an incompatible Node.js version
|
||||
|
||||
REQUIREMENTS:
|
||||
• Node.js version 20+ is required
|
||||
• Current Node.js version: %s
|
||||
|
||||
DEBUGGING STEPS:
|
||||
1. View recent logs: cline log list
|
||||
2. Logs are available in: ~/.cline/logs/
|
||||
3. The most recent cline-core log file is usually valuable
|
||||
|
||||
For additional help, visit: https://github.com/cline/cline/issues
|
||||
`, maxRetries, lastErr, GetNodeVersion())
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ const (
|
||||
OPENAI_NATIVE = "openai-native"
|
||||
XAI = "xai"
|
||||
CEREBRAS = "cerebras"
|
||||
OCA = "oca"
|
||||
)
|
||||
|
||||
// AllProviders returns a slice of enabled provider IDs for the CLI build.
|
||||
@@ -159,6 +160,7 @@ var AllProviders = []string{
|
||||
"openai-native",
|
||||
"xai",
|
||||
"cerebras",
|
||||
"oca",
|
||||
}
|
||||
|
||||
// ConfigField represents a configuration field requirement
|
||||
@@ -441,7 +443,16 @@ var rawConfigFields = ` [
|
||||
"required": false,
|
||||
"fieldType": "string",
|
||||
"placeholder": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "hicapApiKey",
|
||||
"type": "string",
|
||||
"comment": "",
|
||||
"category": "general",
|
||||
"required": true,
|
||||
"fieldType": "password",
|
||||
"placeholder": "Enter your API key"
|
||||
},
|
||||
]`
|
||||
|
||||
// Raw model definitions data (parsed from TypeScript)
|
||||
@@ -467,6 +478,16 @@ var rawModelDefinitions = ` {
|
||||
"supportsImages": true,
|
||||
"supportsPromptCache": true
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
"maxTokens": 8192,
|
||||
"contextWindow": 200000,
|
||||
"inputPrice": 1,
|
||||
"outputPrice": 5,
|
||||
"cacheWritesPrice": 1,
|
||||
"cacheReadsPrice": 0,
|
||||
"supportsImages": true,
|
||||
"supportsPromptCache": true
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"maxTokens": 8192,
|
||||
"contextWindow": 200000,
|
||||
@@ -579,6 +600,16 @@ var rawModelDefinitions = ` {
|
||||
"supportsImages": true,
|
||||
"supportsPromptCache": true
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"maxTokens": 8192,
|
||||
"contextWindow": 200000,
|
||||
"inputPrice": 1,
|
||||
"outputPrice": 5,
|
||||
"cacheWritesPrice": 1,
|
||||
"cacheReadsPrice": 0,
|
||||
"supportsImages": true,
|
||||
"supportsPromptCache": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
"maxTokens": 8192,
|
||||
"contextWindow": 200000,
|
||||
@@ -1389,6 +1420,18 @@ func GetProviderDefinitions() (map[string]ProviderDefinition, error) {
|
||||
HasDynamicModels: false,
|
||||
SetupInstructions: `Get your API key from https://cloud.cerebras.ai/`,
|
||||
}
|
||||
|
||||
// Oca
|
||||
definitions["oca"] = ProviderDefinition{
|
||||
ID: "oca",
|
||||
Name: "Oca",
|
||||
RequiredFields: getFieldsByProvider("oca", configFields, true),
|
||||
OptionalFields: getFieldsByProvider("oca", configFields, false),
|
||||
Models: modelDefinitions["oca"],
|
||||
DefaultModelID: "",
|
||||
HasDynamicModels: false,
|
||||
SetupInstructions: `Configure Oca API credentials`,
|
||||
}
|
||||
|
||||
return definitions, nil
|
||||
}
|
||||
@@ -1415,6 +1458,7 @@ func GetProviderDisplayName(providerID string) string {
|
||||
"openai-native": "OpenAI",
|
||||
"xai": "X AI (Grok)",
|
||||
"cerebras": "Cerebras",
|
||||
"oca": "Oca",
|
||||
}
|
||||
|
||||
if name, exists := displayNames[providerID]; exists {
|
||||
|
||||
@@ -3,9 +3,10 @@ package hostbridge
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -78,7 +79,7 @@ func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(cli.Version),
|
||||
ClineVersion: proto.String(global.CliVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
|
||||
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
-168
@@ -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,169 +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/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",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -228,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