mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 034d8fba70 | |||
| 844ecdee38 | |||
| 128b721c0e | |||
| b5ae93dec9 | |||
| 24b5bfd77e | |||
| e7dc04fe56 | |||
| 42bad19889 | |||
| 5c7bb7c5db | |||
| 8c05a9923d | |||
| 67ed86ada7 | |||
| f212ba20d6 | |||
| 77e3e1e4ed | |||
| 0858ff517a | |||
| 3472e6068c | |||
| a670c1efa2 | |||
| 078f33a285 | |||
| a09f70023e | |||
| 4247f7f0d5 | |||
| 787cd3063d | |||
| 33b3dde9e8 | |||
| 518733d875 | |||
| 3b636e1a76 | |||
| 79fd3a99d9 | |||
| b7a5e3290a | |||
| b1ae417e05 | |||
| 2d496ed62c | |||
| 1704df14e1 | |||
| e65590c9a0 | |||
| a43d375366 | |||
| 7f05e06d8e | |||
| 724c7c778c | |||
| 522d00411d | |||
| a92f9d459d | |||
| 2d58c5e9f2 | |||
| 835204c7eb | |||
| bb4211ff2a | |||
| 261fc7f3d8 | |||
| 554e4d1b94 | |||
| 6476f723d9 | |||
| beb7ada9b7 | |||
| 2292cafbb3 | |||
| c9550bf357 | |||
| 0c63eaac20 | |||
| 8d2ee1dffb | |||
| 0ea263c1b1 | |||
| 8be46f9cfe | |||
| 4469a4fea4 | |||
| feee75b158 | |||
| 09933e7ad5 | |||
| 5b406eca72 | |||
| e80e0ebc40 | |||
| d14d05547d | |||
| c3e24e5d6b | |||
| 5572096947 | |||
| a85acf8898 | |||
| 1eaeb1812d | |||
| 9f25f8ae42 | |||
| 816c7ede4a | |||
| 29139a6e13 | |||
| c1195746d1 | |||
| a33a3d9186 | |||
| e25fbc1397 | |||
| 2917cd234c | |||
| 63a3896669 | |||
| 529bb3a26c | |||
| 541b51c0fb | |||
| 77395a3088 | |||
| 124fc6f147 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added getCwdHash proto
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added updateApiConfigurationPartial with FieldMask to allow for partial ApiProvider updates
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added checkpoints warning when users start a multiroot task
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add auto-retry with exponential backof for failed API requests
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added subscribeToCheckpoints proto
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
auto-cleanup stale default instance config
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add interactive provider configuration wizard with add/list capabilities, support for 8 API providers (Anthropic, OpenAI, OpenAI Native, OpenRouter, X AI, AWS Bedrock, Google Gemini, Ollama), and UpdateSettings gRPC implementation for persisting configurations to Cline Core state.
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse Hook Example
|
||||
#
|
||||
# This hook runs AFTER a tool is executed. It can:
|
||||
# 1. Observe tool results and outcomes
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Log or track tool usage patterns
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool has already completed when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}')
|
||||
result=$(echo "$input" | jq -r '.postToolUse.result // ""')
|
||||
success=$(echo "$input" | jq -r '.postToolUse.success // false')
|
||||
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0')
|
||||
|
||||
# Example 1: Learning from file operations
|
||||
# Track successful file creations to build context about project structure
|
||||
# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "FILE_OPERATIONS: Successfully created '$path'. Future operations should maintain consistency with this file's patterns and structure."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 2: Performance monitoring
|
||||
# Warn about slow operations
|
||||
# if [[ "$execution_time" -gt 5000 ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": true,
|
||||
# "contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms to complete. Consider optimizing future similar operations or breaking them into smaller steps."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
|
||||
# Example 3: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "TOOL_RESULT: The tool '$tool_name' completed with success=$success. Consider validating the results before proceeding to the next step."
|
||||
}
|
||||
EOF
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
REM PostToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs AFTER a tool is executed. It can:
|
||||
REM 1. Observe tool results and outcomes
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Log or track tool usage patterns
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
REM The tool has already completed when this hook runs.
|
||||
|
||||
REM Simple example: Always allow continuation
|
||||
echo {"shouldContinue": true}
|
||||
|
||||
REM To add context based on results, use:
|
||||
REM echo {"shouldContinue": true, "contextModification": "TOOL_RESULT: Operation completed successfully"}
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook - Advanced Example with Input Parsing
|
||||
REM This version reads and parses the JSON input from stdin using PowerShell
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read all input from stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse JSON and make decisions using PowerShell
|
||||
REM Note: We use -replace to handle special characters in the input
|
||||
powershell -NoProfile -Command ^
|
||||
"$input = '%INPUT%' -replace \"'\", \"''\"; ^
|
||||
try { ^
|
||||
$json = $input | ConvertFrom-Json; ^
|
||||
$toolName = $json.preToolUse.toolName; ^
|
||||
$shouldBlock = $false; ^
|
||||
$errorMsg = ''; ^
|
||||
$context = ''; ^
|
||||
if ($toolName -eq 'write_to_file') { ^
|
||||
$path = $json.preToolUse.parameters.path; ^
|
||||
if ($path -match '\\.js$') { ^
|
||||
$shouldBlock = $true; ^
|
||||
$errorMsg = 'Cannot create .js files in TypeScript project'; ^
|
||||
$context = 'WORKSPACE_RULES: Use .ts/.tsx extensions only'; ^
|
||||
} ^
|
||||
} ^
|
||||
$output = @{ ^
|
||||
shouldContinue = -not $shouldBlock; ^
|
||||
}; ^
|
||||
if ($errorMsg) { $output.errorMessage = $errorMsg }; ^
|
||||
if ($context) { $output.contextModification = $context }; ^
|
||||
$output | ConvertTo-Json -Compress; ^
|
||||
} catch { ^
|
||||
@{ shouldContinue = $true } | ConvertTo-Json -Compress; ^
|
||||
}"
|
||||
|
||||
endlocal
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse Hook Example
|
||||
#
|
||||
# This hook runs BEFORE a tool is executed. It can:
|
||||
# 1. Block execution by returning {"shouldContinue": false}
|
||||
# 2. Add context for FUTURE tool uses via contextModification
|
||||
# 3. Validate tool parameters
|
||||
#
|
||||
# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
# The tool parameters are already determined when this hook runs.
|
||||
|
||||
# Read the hook input (JSON via stdin)
|
||||
input=$(cat)
|
||||
|
||||
# Extract tool information
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // "unknown"')
|
||||
parameters=$(echo "$input" | jq -r '.preToolUse.parameters // {}')
|
||||
|
||||
# Example 1: Validation - Block invalid operations
|
||||
# Uncomment to prevent creating .js files in a TypeScript project
|
||||
# if [[ "$tool_name" == "write_to_file" ]]; then
|
||||
# path=$(echo "$parameters" | jq -r '.path // ""')
|
||||
# if [[ "$path" == *.js ]]; then
|
||||
# cat <<EOF
|
||||
# {
|
||||
# "shouldContinue": false,
|
||||
# "errorMessage": "VALIDATION FAILED: Cannot create .js files in TypeScript project. Please use .ts extension instead.",
|
||||
# "contextModification": "WORKSPACE_RULES: This is a strict TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
# }
|
||||
# EOF
|
||||
# exit 0
|
||||
# fi
|
||||
# fi
|
||||
|
||||
# Example 2: Context injection for future tool uses
|
||||
# The context will be available in the NEXT API request after this tool completes
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "WORKSPACE_RULES: [For future tool uses] This is a TypeScript React project. When creating files, use .ts/.tsx extensions and include detailed comments explaining the purpose and usage of each function."
|
||||
}
|
||||
EOF
|
||||
@@ -0,0 +1,15 @@
|
||||
@echo off
|
||||
REM PreToolUse Hook Example - Windows Batch Version
|
||||
REM
|
||||
REM This hook runs BEFORE a tool is executed. It can:
|
||||
REM 1. Block execution by returning {"shouldContinue": false}
|
||||
REM 2. Add context for FUTURE tool uses via contextModification
|
||||
REM 3. Validate tool parameters
|
||||
REM
|
||||
REM IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution.
|
||||
|
||||
REM Simple example: Always allow execution with workspace context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: This is a TypeScript project. Use .ts/.tsx extensions for new files."}
|
||||
|
||||
REM To block execution, use:
|
||||
REM echo {"shouldContinue": false, "errorMessage": "Operation not allowed"}
|
||||
@@ -0,0 +1,290 @@
|
||||
# Cline Hooks Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks are placed in the `.clinerules/hooks/` directory and run automatically when enabled.
|
||||
|
||||
## Enabling Hooks
|
||||
|
||||
1. Open Cline settings in VSCode
|
||||
2. Navigate to the Feature Settings section
|
||||
3. Check the "Enable Hooks" checkbox
|
||||
4. Hooks must be executable files (on Unix/Linux/macOS use `chmod +x hookname`)
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### PreToolUse Hook
|
||||
- **When**: Runs BEFORE a tool is executed
|
||||
- **Purpose**: Validate parameters, block execution, or add context
|
||||
- **File**: `.clinerules/hooks/PreToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PreToolUse.bat/.cmd/.exe` (Windows)
|
||||
|
||||
### PostToolUse Hook
|
||||
- **When**: Runs AFTER a tool completes
|
||||
- **Purpose**: Observe results, track patterns, or add context
|
||||
- **File**: `.clinerules/hooks/PostToolUse` (Unix/Linux/macOS) or `.clinerules/hooks/PostToolUse.bat/.cmd/.exe` (Windows)
|
||||
|
||||
## Platform-Specific Guidance
|
||||
|
||||
### Windows Hooks
|
||||
|
||||
Windows hooks use different file extensions and syntax than Unix hooks. Cline automatically searches for hooks using your system's `PATHEXT` environment variable (typically `.COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.MSC`).
|
||||
|
||||
**Recommended approach for Windows:**
|
||||
- Use `.cmd` or `.bat` batch files (most compatible)
|
||||
- See `PreToolUse.example.cmd` and `PostToolUse.example.cmd` for simple examples
|
||||
- See `PreToolUse.advanced.example.cmd` for PowerShell-based JSON parsing
|
||||
|
||||
**Simple Windows Hook Example:**
|
||||
```batch
|
||||
@echo off
|
||||
REM Always allow execution with context
|
||||
echo {"shouldContinue": true, "contextModification": "WORKSPACE_RULES: TypeScript project"}
|
||||
```
|
||||
|
||||
**Advanced Windows Hook with Input Parsing:**
|
||||
```batch
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Read stdin using PowerShell
|
||||
for /f "usebackq delims=" %%i in (`powershell -Command "[Console]::In.ReadToEnd()"`) do set "INPUT=%%i"
|
||||
|
||||
REM Parse and process JSON
|
||||
powershell -Command ^
|
||||
"$json = '%INPUT%' | ConvertFrom-Json; ^
|
||||
$output = @{shouldContinue = $true}; ^
|
||||
$output | ConvertTo-Json -Compress"
|
||||
```
|
||||
|
||||
**Tips for Windows:**
|
||||
- Batch files don't require `chmod +x` - they're executable by default
|
||||
- Use `REM` for comments instead of `#`
|
||||
- PowerShell is available on all modern Windows systems
|
||||
- For complex logic, consider PowerShell scripts (`.ps1`) or compiled executables (`.exe`)
|
||||
|
||||
### Unix/Linux/macOS Hooks
|
||||
|
||||
Unix hooks are shell scripts without file extensions:
|
||||
- Must be executable: `chmod +x PreToolUse`
|
||||
- Must include shebang: `#!/usr/bin/env bash` or `#!/usr/bin/env node`
|
||||
- See `PreToolUse.example` and `PostToolUse.example` for bash examples
|
||||
|
||||
## Context Injection Timing
|
||||
|
||||
**IMPORTANT**: Context injected by hooks affects **FUTURE AI decisions**, not the current tool execution.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
When a hook runs:
|
||||
1. The AI has already decided what tool to use and with what parameters
|
||||
2. The hook cannot modify those parameters
|
||||
3. Context from the hook is added to the conversation
|
||||
4. The AI sees this context in the **NEXT API request** and can adjust future decisions
|
||||
|
||||
### PreToolUse Hook Flow
|
||||
```
|
||||
1. AI decides: "I'll use write_to_file with these parameters"
|
||||
2. PreToolUse hook runs → can block or add context
|
||||
3. If allowed, tool executes with original parameters
|
||||
4. Context is added to conversation
|
||||
5. Next API request includes this context
|
||||
6. AI adjusts future decisions based on context
|
||||
```
|
||||
|
||||
### PostToolUse Hook Flow
|
||||
```
|
||||
1. Tool completes execution
|
||||
2. PostToolUse hook runs → observes results
|
||||
3. Hook adds context about the outcome
|
||||
4. Context is added to conversation
|
||||
5. Next API request includes this context
|
||||
6. AI can learn from the results
|
||||
```
|
||||
|
||||
## Hook Input/Output
|
||||
|
||||
### Input (via stdin as JSON)
|
||||
|
||||
All hooks receive:
|
||||
```json
|
||||
{
|
||||
"clineVersion": "string",
|
||||
"hookName": "PreToolUse" | "PostToolUse",
|
||||
"timestamp": "string",
|
||||
"taskId": "string",
|
||||
"workspaceRoots": ["string"],
|
||||
"userId": "string",
|
||||
"preToolUse": { // Only for PreToolUse
|
||||
"toolName": "string",
|
||||
"parameters": {}
|
||||
},
|
||||
"postToolUse": { // Only for PostToolUse
|
||||
"toolName": "string",
|
||||
"parameters": {},
|
||||
"result": "string",
|
||||
"success": boolean,
|
||||
"executionTimeMs": number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Output (via stdout as JSON)
|
||||
|
||||
All hooks must return:
|
||||
```json
|
||||
{
|
||||
"shouldContinue": boolean, // Required: Allow or block execution
|
||||
"contextModification": "string", // Optional: Context for future tool uses
|
||||
"errorMessage": "string" // Optional: Error details if blocking
|
||||
}
|
||||
```
|
||||
|
||||
## Context Modification Format
|
||||
|
||||
Use structured prefixes to help the AI understand context type:
|
||||
|
||||
- `WORKSPACE_RULES:` - Project conventions and requirements
|
||||
- `FILE_OPERATIONS:` - File creation/modification patterns
|
||||
- `TOOL_RESULT:` - Outcomes of tool executions
|
||||
- `PERFORMANCE:` - Performance concerns
|
||||
- `VALIDATION:` - Validation results
|
||||
- Custom prefixes as needed
|
||||
|
||||
Example:
|
||||
```bash
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "WORKSPACE_RULES: This is a TypeScript project. All new files must use .ts or .tsx extensions."
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## Hook Execution Limits
|
||||
|
||||
- **Timeout**: Hooks must complete within 30 seconds
|
||||
- **Context Size**: Context modifications are limited to 50KB
|
||||
- **Error Handling**: Unexpected file system errors are propagated; expected errors (file not found, permission denied) are handled silently
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Validation - Block Invalid Operations
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
|
||||
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
|
||||
|
||||
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": false,
|
||||
"errorMessage": "Cannot create .js files in TypeScript project",
|
||||
"contextModification": "WORKSPACE_RULES: Use .ts/.tsx extensions only"
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
### 2. Context Building - Learn from Operations
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
|
||||
success=$(echo "$input" | jq -r '.postToolUse.success')
|
||||
path=$(echo "$input" | jq -r '.postToolUse.parameters.path // ""')
|
||||
|
||||
if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "FILE_OPERATIONS: Created '$path'. Maintain consistency with this file's patterns in future operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
fi
|
||||
```
|
||||
|
||||
### 3. Performance Monitoring
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs')
|
||||
tool_name=$(echo "$input" | jq -r '.postToolUse.toolName')
|
||||
|
||||
if [[ "$execution_time" -gt 5000 ]]; then
|
||||
cat <<EOF
|
||||
{
|
||||
"shouldContinue": true,
|
||||
"contextModification": "PERFORMANCE: Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo '{"shouldContinue": true}'
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. Logging and Telemetry
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
input=$(cat)
|
||||
|
||||
# Log to file
|
||||
echo "$input" >> ~/.cline/hook-logs/tool-usage.jsonl
|
||||
|
||||
# Allow execution
|
||||
echo '{"shouldContinue": true}'
|
||||
```
|
||||
|
||||
## Multi-Root Workspaces
|
||||
|
||||
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks will run and their results will be combined:
|
||||
|
||||
- **shouldContinue**: If ANY hook returns false, execution is blocked
|
||||
- **contextModification**: All context modifications are concatenated
|
||||
- **errorMessage**: All error messages are concatenated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hook Not Running
|
||||
- Ensure the "Enable Hooks" setting is checked
|
||||
- Verify the hook file is executable (`chmod +x hookname`)
|
||||
- Check the hook file has no syntax errors
|
||||
- Look for errors in VSCode's Output panel (Cline channel)
|
||||
|
||||
### Hook Timing Out
|
||||
- Reduce complexity of the hook script
|
||||
- Avoid expensive operations (network calls, heavy computations)
|
||||
- Consider moving complex logic to a background process
|
||||
|
||||
### Context Not Affecting Behavior
|
||||
- Remember: context affects FUTURE decisions, not the current tool
|
||||
- Use PreToolUse for validation (blocking) if you need immediate effect
|
||||
- Ensure context modifications are clear and actionable
|
||||
- Check that context isn't being truncated (50KB limit)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Hooks run with the same permissions as VSCode
|
||||
- Be cautious with hooks from untrusted sources
|
||||
- Review hook scripts before enabling them
|
||||
- Consider using `.gitignore` to avoid committing sensitive hook logic
|
||||
- Hooks can access all workspace files and environment variables
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep hooks fast** - Aim for <100ms execution time
|
||||
2. **Make context actionable** - Be specific about what the AI should do
|
||||
3. **Use structured prefixes** - Help the AI categorize context
|
||||
4. **Handle errors gracefully** - Always return valid JSON
|
||||
5. **Log for debugging** - Keep logs of hook executions for troubleshooting
|
||||
6. **Test incrementally** - Start with simple hooks and add complexity
|
||||
7. **Document your hooks** - Add comments explaining the purpose and logic
|
||||
@@ -0,0 +1,48 @@
|
||||
# Cline Development Environment Variables
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# Values should be obtained from 1Password shared vault for development
|
||||
|
||||
# ============================================================================
|
||||
# DEVELOPMENT FLAGS
|
||||
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
|
||||
# ============================================================================
|
||||
# IS_DEV=true
|
||||
# CLINE_ENVIRONMENT=local
|
||||
|
||||
# ============================================================================
|
||||
# POSTHOG TELEMETRY (Existing)
|
||||
# ============================================================================
|
||||
# Get these values from 1Password shared vault
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL DEVELOPMENT SETTINGS
|
||||
# ============================================================================
|
||||
# Uncomment and modify as needed for development
|
||||
|
||||
# Multi-root workspace debugging
|
||||
# MULTI_ROOT_TRACE=true
|
||||
|
||||
# gRPC recorder for testing
|
||||
# GRPC_RECORDER_ENABLED=true
|
||||
# GRPC_RECORDER_FILE_NAME=test-recording
|
||||
|
||||
# Test mode
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
# ============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# ============================================================================
|
||||
# 1. Copy this file: cp .env.example .env
|
||||
# 2. Get PostHog keys from 1Password shared vault
|
||||
# 3. Update the values in .env
|
||||
# 4. The .env file is gitignored for security
|
||||
@@ -1,3 +1,4 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
|
||||
@@ -14,6 +14,7 @@ body:
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
- CLI
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Auto-label Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['JetBrains']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['VS Code']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['CLI']
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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
|
||||
@@ -187,8 +187,20 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Compile standalone
|
||||
run: npm run compile-standalone
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Download Node.js binaries
|
||||
run: npm run download-node
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli
|
||||
|
||||
- name: Compile standalone CLI
|
||||
run: npm run compile-standalone-cli
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
@@ -244,11 +256,14 @@ jobs:
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
id: download-integration-coverage
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
|
||||
@@ -26,6 +26,7 @@ coverage-unit
|
||||
!.github/scripts/coverage/
|
||||
|
||||
*evals.env
|
||||
.env
|
||||
|
||||
## Generated files ##
|
||||
src/generated/
|
||||
|
||||
Vendored
+6
@@ -19,6 +19,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -39,6 +40,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -59,6 +61,7 @@
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
@@ -84,6 +87,7 @@
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
@@ -114,6 +118,7 @@
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
@@ -151,6 +156,7 @@
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
|
||||
+7
-1
@@ -1,10 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.7]
|
||||
|
||||
- Add JP and Global inference profile options to AWS Bedrock
|
||||
- Adding Improvements to VSCode multi root workspaces
|
||||
- Added markdown support to focus chain text, allowing the model to display more interesting focus chains
|
||||
|
||||
## [3.32.6]
|
||||
|
||||
- Add experimental support for VSCode multi root workspaces
|
||||
- Add Claude Sonnet 4.5 to Claude Code provider
|
||||
- Add Glm 4.6 to Z AI provider
|
||||
- Add Glm 4.6 to Z AI provider
|
||||
|
||||
## [3.32.5]
|
||||
|
||||
|
||||
+89
-7
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
@@ -13,45 +15,125 @@ import (
|
||||
|
||||
var (
|
||||
coreAddress string
|
||||
cfgFile string
|
||||
verbose bool
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
workspaces []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "cline",
|
||||
Use: "cline [prompt]",
|
||||
Short: "Cline CLI - AI-powered coding assistant",
|
||||
Long: `A command-line interface for interacting with Cline AI coding assistant.
|
||||
|
||||
This CLI provides access to Cline's task management, configuration, and
|
||||
monitoring capabilities from the terminal.`,
|
||||
Start a new task by providing a prompt:
|
||||
cline "Create a new Python script that prints hello world"
|
||||
|
||||
Or run with no arguments to enter interactive mode:
|
||||
cline
|
||||
|
||||
This CLI also provides task management, configuration, and monitoring capabilities.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
|
||||
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
|
||||
}
|
||||
|
||||
return global.InitializeGlobalConfig(&global.GlobalConfig{
|
||||
ConfigPath: cfgFile,
|
||||
Verbose: verbose,
|
||||
OutputFormat: outputFormat,
|
||||
CoreAddress: coreAddress,
|
||||
})
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var prompt string
|
||||
|
||||
// If args provided, use as prompt
|
||||
if len(args) > 0 {
|
||||
prompt = strings.Join(args, " ")
|
||||
} else {
|
||||
// Show interactive input to get prompt
|
||||
var err error
|
||||
prompt, err = promptForInitialTask()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cline/config.yaml)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)")
|
||||
|
||||
// Task creation flags (only apply when using root command with prompt)
|
||||
rootCmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
rootCmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
rootCmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan")
|
||||
rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)")
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
rootCmd.AddCommand(cli.NewConfigCommand())
|
||||
rootCmd.AddCommand(cli.NewVersionCommand())
|
||||
rootCmd.AddCommand(cli.NewAuthCommand())
|
||||
rootCmd.AddCommand(cli.NewTaskSendCommand())
|
||||
|
||||
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask() (string, error) {
|
||||
var prompt string
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Start a new Cline task").
|
||||
Description("What would you like Cline to help you with?").
|
||||
Placeholder("e.g., Create a REST API with authentication...").
|
||||
Lines(5).
|
||||
Value(&prompt),
|
||||
),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(prompt), nil
|
||||
}
|
||||
|
||||
+37
-1
@@ -3,20 +3,56 @@ module github.com/cline/cli
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.7.0
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.32.0
|
||||
google.golang.org/grpc v1.75.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
)
|
||||
|
||||
replace github.com/cline/grpc-go => ../src/generated/grpc-go
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.4 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
|
||||
+102
@@ -1,4 +1,62 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
|
||||
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -9,15 +67,51 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
@@ -30,10 +124,18 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
|
||||
+2
-110
@@ -1,125 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
func NewAuthCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Sign in to Cline",
|
||||
Long: `Complete the authentication flow in browser to sign in to Cline.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return handleAuthCommand(cmd.Context())
|
||||
return auth.HandleAuthCommand(cmd.Context(), args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthCommand(ctx context.Context) error {
|
||||
fmt.Print("Authenticating with Cline...\n")
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("You are signed in!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
if !scanner.Scan() {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||
if response == "y" || response == "yes" {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
verboseLog("Ensuring default instance exists...")
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
verboseLog("Failed to ensure default instance: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
verboseLog("Default instance ensured successfully.")
|
||||
time.Sleep(2 * time.Second) // Allow services to start
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to login: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
return true
|
||||
}
|
||||
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
var isSessionAuthenticated bool
|
||||
|
||||
// Cline provider specific code
|
||||
|
||||
func HandleClineAuth(ctx context.Context) error {
|
||||
fmt.Println("Authenticating with Cline...")
|
||||
|
||||
// Check if already authenticated
|
||||
if IsAuthenticated(ctx) {
|
||||
return signOutDialog(ctx)
|
||||
}
|
||||
|
||||
// Perform sign in
|
||||
if err := signIn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✓ You are signed in!")
|
||||
|
||||
// Configure default Cline model after successful authentication
|
||||
if err := configureDefaultClineModel(ctx); err != nil {
|
||||
fmt.Printf("Warning: Could not configure default Cline model: %v\n", err)
|
||||
fmt.Println("You can configure a model later with 'cline auth' and selecting 'Change Cline model'")
|
||||
}
|
||||
|
||||
// Return to main auth menu after successful authentication
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signOut(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isSessionAuthenticated = false
|
||||
fmt.Println("You have been signed out of Cline.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signOutDialog(ctx context.Context) error {
|
||||
var confirm bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("You are already signed in to Cline.").
|
||||
Description("Would you like to sign out?").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if confirm {
|
||||
if err := signOut(ctx); err != nil {
|
||||
fmt.Printf("Failed to sign out: %v\n", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
func signIn(ctx context.Context) error {
|
||||
if IsAuthenticated(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
verboseLog("Failed to subscribe to auth updates: %v", err)
|
||||
return fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
defer listener.Stop()
|
||||
|
||||
if err := listener.Start(); err != nil {
|
||||
verboseLog("Failed to start auth listener: %v", err)
|
||||
return fmt.Errorf("failed to start auth listener: %w", err)
|
||||
}
|
||||
|
||||
// Initiate login (opens browser with callback URL from cline-core's AuthHandler)
|
||||
verboseLog("Initiating login...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to obtain client: %v", err)
|
||||
return fmt.Errorf("failed to obtain client: %w", err)
|
||||
}
|
||||
|
||||
_, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
verboseLog("Failed to initiate login: %v", err)
|
||||
return fmt.Errorf("failed to initiate login: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n Opening browser for authentication...")
|
||||
fmt.Println(" Waiting for you to complete authentication in your browser...")
|
||||
fmt.Println(" (This may take a few moments. Timeout: 5 minutes)")
|
||||
|
||||
// Wait for auth status update confirming success
|
||||
verboseLog("Waiting for authentication to complete...")
|
||||
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
|
||||
verboseLog("Authentication failed or timed out: %v", err)
|
||||
fmt.Println("\n Authentication failed or timed out.")
|
||||
fmt.Println(" Please try again with 'cline auth'")
|
||||
return err
|
||||
}
|
||||
|
||||
// Only NOW set the session flag after confirmed authentication
|
||||
isSessionAuthenticated = true
|
||||
verboseLog("Login successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsAuthenticated(ctx context.Context) bool {
|
||||
if isSessionAuthenticated {
|
||||
verboseLog("Session is already authenticated")
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Verifying authentication with server...")
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
verboseLog("Failed to get client for auth check: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
|
||||
if err == nil {
|
||||
// Update session variable for future fast-path checks
|
||||
verboseLog("Server verification successful, updating session flag")
|
||||
isSessionAuthenticated = true
|
||||
return true
|
||||
}
|
||||
|
||||
verboseLog("Server verification failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleChangeClineModel allows Cline-authenticated users to change their Cline model selection. Hidden when not authenticated.
|
||||
func HandleChangeClineModel(ctx context.Context) error {
|
||||
// Ensure user is authenticated
|
||||
if !IsAuthenticated(ctx) {
|
||||
return fmt.Errorf("you must be authenticated with Cline to change models. Run 'cline auth' to sign in")
|
||||
}
|
||||
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Launch Cline model selection
|
||||
return SelectClineModel(ctx, manager)
|
||||
}
|
||||
|
||||
// configureDefaultClineModel configures the default Cline model after authentication
|
||||
func configureDefaultClineModel(ctx context.Context) error {
|
||||
verboseLog("Configuring default Cline model...")
|
||||
|
||||
// Create task manager
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Set default Cline model
|
||||
return SetDefaultClineModel(ctx, manager)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// AuthAction represents the type of authentication action
|
||||
type AuthAction string
|
||||
|
||||
const (
|
||||
AuthActionClineLogin AuthAction = "cline_login"
|
||||
AuthActionBYOSetup AuthAction = "provider_setup"
|
||||
AuthActionChangeClineModel AuthAction = "change_cline_model"
|
||||
AuthActionSelectProvider AuthAction = "select_provider"
|
||||
AuthActionExit AuthAction = "exit_wizard"
|
||||
)
|
||||
|
||||
// Cline Auth Menu
|
||||
// Example Layout
|
||||
//
|
||||
// ┃ Cline Account: <authenticated/not authenticated>
|
||||
// ┃ Active Provider: <provider name or none configured>
|
||||
// ┃ Active Model: <model name or none configured>
|
||||
// ┃
|
||||
// ┃ What would you like to do?
|
||||
// ┃ Change Cline model (only if authenticated) - hidden if not authenticated
|
||||
// ┃ Authenticate with Cline account / Sign out of Cline - changes based on auth status
|
||||
// ┃ Select active provider (Cline or BYO) - always shown. Used to switch between Cline and BYO providers
|
||||
// ┃ Configure API provider - always shown. Launches provider setup wizard
|
||||
// ┃ Exit authorization wizard - always shown. Exits the auth menu
|
||||
|
||||
// Main entry point for handling the `cline auth` command
|
||||
// HandleAuthCommand routes the auth command based on the number of arguments
|
||||
func HandleAuthCommand(ctx context.Context, args []string) error {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// No args: Show menu (ShowAuthMenuNoArgs)
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
case 1:
|
||||
// One arg: Provider ID only, prompt for API key
|
||||
return QuickAPISetup(args[0], "")
|
||||
case 2:
|
||||
// Two args: Provider ID and API key
|
||||
return QuickAPISetup(args[0], args[1])
|
||||
default:
|
||||
return fmt.Errorf("quick BYO API setup is currently stubbed - not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAuthMenuNoArgs prepares the auth menu when no arguments are provided
|
||||
func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
// Check if Cline is authenticated
|
||||
isClineAuth := IsAuthenticated(ctx)
|
||||
|
||||
// Get current provider config for display
|
||||
var currentProvider string
|
||||
var currentModel string
|
||||
if manager, err := createTaskManager(ctx); err == nil {
|
||||
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = getProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
action, err := ShowAuthMenuWithStatus(isClineAuth, currentProvider, currentModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case AuthActionClineLogin:
|
||||
return HandleClineAuth(ctx)
|
||||
case AuthActionBYOSetup:
|
||||
return HandleAPIProviderSetup(ctx)
|
||||
case AuthActionChangeClineModel:
|
||||
return HandleChangeClineModel(ctx)
|
||||
case AuthActionSelectProvider:
|
||||
return HandleSelectProvider(ctx)
|
||||
case AuthActionExit:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid action")
|
||||
}
|
||||
}
|
||||
|
||||
// ShowAuthMenuWithStatus displays the main auth menu with Cline + provider status
|
||||
func ShowAuthMenuWithStatus(isClineAuthenticated bool, currentProvider, currentModel string) (AuthAction, error) {
|
||||
var action AuthAction
|
||||
var options []huh.Option[AuthAction]
|
||||
|
||||
// Build menu options based on authentication status
|
||||
if isClineAuthenticated {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Change Cline model", AuthActionChangeClineModel),
|
||||
huh.NewOption("Sign out of Cline", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure API provider", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
}
|
||||
} else {
|
||||
options = []huh.Option[AuthAction]{
|
||||
huh.NewOption("Authenticate with Cline account", AuthActionClineLogin),
|
||||
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
|
||||
huh.NewOption("Configure API provider", AuthActionBYOSetup),
|
||||
huh.NewOption("Exit authorization wizard", AuthActionExit),
|
||||
}
|
||||
}
|
||||
|
||||
// Determine menu title based on status
|
||||
var title string
|
||||
|
||||
// Always show Cline authentication status
|
||||
if isClineAuthenticated {
|
||||
title = "Cline Account: \033[32m✓\033[0m Authenticated\n"
|
||||
} else {
|
||||
title = "Cline Account: \033[31m✗\033[0m Not authenticated\n"
|
||||
}
|
||||
|
||||
// Show active provider and model if configured (regardless of Cline auth status)
|
||||
// ANSI color codes: Normal intensity = \033[22m, White = \033[37m, Reset = \033[0m
|
||||
if currentProvider != "" && currentModel != "" {
|
||||
title += fmt.Sprintf("Active Provider: \033[22m\033[37m%s\033[0m\nActive Model: \033[22m\033[37m%s\033[0m\n", currentProvider, currentModel)
|
||||
}
|
||||
|
||||
// Always end with a huh?
|
||||
title += "\nWhat would you like to do?"
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[AuthAction]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// HandleAPIProviderSetup launches the API provider configuration wizard
|
||||
func HandleAPIProviderSetup(ctx context.Context) error {
|
||||
wizard, err := NewProviderWizard(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create provider wizard: %w", err)
|
||||
}
|
||||
|
||||
return wizard.Run()
|
||||
}
|
||||
|
||||
// HandleSelectProvider allows users to switch between Cline provider and BYO providers
|
||||
func HandleSelectProvider(ctx context.Context) error {
|
||||
// Get task manager
|
||||
manager, err := createTaskManager(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
// Detect all providers with valid configurations (is an API key present)
|
||||
availableProviders, err := DetectAllConfiguredProviders(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect configured providers: %w", err)
|
||||
}
|
||||
|
||||
// Build list of available providers
|
||||
var providerOptions []huh.Option[string]
|
||||
var providerMapping = make(map[string]cline.ApiProvider)
|
||||
|
||||
// Add each configured provider to the selection menu
|
||||
for _, provider := range availableProviders {
|
||||
providerName := getProviderDisplayName(provider)
|
||||
providerKey := fmt.Sprintf("provider_%d", provider)
|
||||
providerOptions = append(providerOptions, huh.NewOption(providerName, providerKey))
|
||||
providerMapping[providerKey] = provider
|
||||
}
|
||||
|
||||
if len(providerOptions) == 0 {
|
||||
fmt.Println("No providers available. Please configure a provider first.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
if len(providerOptions) == 1 {
|
||||
fmt.Println("Only one provider is configured. Configure another provider to switch between them.")
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
providerOptions = append(providerOptions, huh.NewOption("(Cancel)", "cancel"))
|
||||
|
||||
// Show selection menu
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select which provider to use").
|
||||
Options(providerOptions...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selected == "cancel" {
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// Get the selected provider
|
||||
selectedProvider := providerMapping[selected]
|
||||
|
||||
// Apply the selected provider
|
||||
if selectedProvider == cline.ApiProvider_CLINE {
|
||||
// Configure Cline as the active provider
|
||||
return SelectClineModel(ctx, manager)
|
||||
} else {
|
||||
// Switch to the selected BYO provider
|
||||
return SwitchToBYOProvider(ctx, manager, selectedProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// createTaskManager is a helper to create a task manager (avoids import cycles)
|
||||
func createTaskManager(ctx context.Context) (*task.Manager, error) {
|
||||
return task.NewManagerForDefault(ctx)
|
||||
}
|
||||
|
||||
func verboseLog(format string, args ...interface{}) {
|
||||
if global.Config != nil && global.Config.Verbose {
|
||||
fmt.Printf("[VERBOSE] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// AuthStatusListener manages subscription to auth status updates
|
||||
type AuthStatusListener struct {
|
||||
stream cline.AccountService_SubscribeToAuthStatusUpdateClient
|
||||
updatesCh chan *cline.AuthState
|
||||
errCh chan error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewAuthStatusListener creates a new auth status listener
|
||||
func NewAuthStatusListener(parentCtx context.Context) (*AuthStatusListener, error) {
|
||||
client, err := global.GetDefaultClient(parentCtx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Create cancellable context
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
|
||||
// Subscribe to auth status updates
|
||||
stream, err := client.Account.SubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to subscribe to auth updates: %w", err)
|
||||
}
|
||||
|
||||
return &AuthStatusListener{
|
||||
stream: stream,
|
||||
updatesCh: make(chan *cline.AuthState, 10),
|
||||
errCh: make(chan error, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening to the auth status update stream
|
||||
func (l *AuthStatusListener) Start() error {
|
||||
verboseLog("Starting auth status listener...")
|
||||
|
||||
go l.readStream()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStream reads from the gRPC stream and forwards messages to channels
|
||||
func (l *AuthStatusListener) readStream() {
|
||||
defer close(l.updatesCh)
|
||||
defer close(l.errCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
verboseLog("Auth listener context cancelled")
|
||||
return
|
||||
default:
|
||||
state, err := l.stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
verboseLog("Auth status stream closed")
|
||||
return
|
||||
}
|
||||
verboseLog("Error reading from auth status stream: %v", err)
|
||||
select {
|
||||
case l.errCh <- err:
|
||||
case <-l.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
verboseLog("Received auth state update: user=%v", state.User != nil)
|
||||
|
||||
select {
|
||||
case l.updatesCh <- state:
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForAuthentication blocks until authentication succeeds or timeout occurs
|
||||
func (l *AuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
|
||||
verboseLog("Waiting for authentication (timeout: %v)...", timeout)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("authentication timeout after %v - please try again", timeout)
|
||||
|
||||
case <-l.ctx.Done():
|
||||
return fmt.Errorf("authentication cancelled")
|
||||
|
||||
case err := <-l.errCh:
|
||||
return fmt.Errorf("authentication stream error: %w", err)
|
||||
|
||||
case state := <-l.updatesCh:
|
||||
if isAuthenticated(state) {
|
||||
verboseLog("Authentication successful!")
|
||||
return nil
|
||||
}
|
||||
verboseLog("Received auth update but not authenticated yet...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop closes the stream and cleans up resources
|
||||
func (l *AuthStatusListener) Stop() {
|
||||
verboseLog("Stopping auth status listener...")
|
||||
l.cancel()
|
||||
}
|
||||
|
||||
// isAuthenticated checks if AuthState indicates successful authentication
|
||||
func isAuthenticated(state *cline.AuthState) bool {
|
||||
return state != nil && state.User != nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
|
||||
// QuickAPISetup performs quick provider setup with provider ID and optional API key
|
||||
func QuickAPISetup(providerID, apiKey string) error {
|
||||
fmt.Println("Quick BYO API setup is currently stubbed - not yet implemented.")
|
||||
fmt.Printf("Requested provider: %s\n", providerID)
|
||||
if apiKey != "" {
|
||||
fmt.Println("Provided API key:", "<jk redacted>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package auth
|
||||
@@ -0,0 +1,123 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// DefaultClineModelID is the default model ID for Cline provider.
|
||||
// Cline uses OpenRouter-compatible model IDs.
|
||||
const DefaultClineModelID = "anthropic/claude-sonnet-4.5"
|
||||
|
||||
// FetchClineModels fetches available Cline models from Cline Core.
|
||||
// Note: Cline provider uses OpenRouter-compatible API and model format.
|
||||
// The models are fetched using the same method as OpenRouter.
|
||||
func FetchClineModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Fetching Cline models (using OpenRouter-compatible API)")
|
||||
}
|
||||
|
||||
// Cline uses OpenRouter model fetching
|
||||
models, err := FetchOpenRouterModels(ctx, manager)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// GetClineModelInfo retrieves information for a specific Cline model.
|
||||
func GetClineModelInfo(modelID string, models map[string]*cline.OpenRouterModelInfo) (*cline.OpenRouterModelInfo, error) {
|
||||
modelInfo, exists := models[modelID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("model %s not found", modelID)
|
||||
}
|
||||
return modelInfo, nil
|
||||
}
|
||||
|
||||
// SetDefaultClineModel configures the default Cline model after authentication.
|
||||
// This is called automatically after successful Cline sign-in.
|
||||
func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch available models
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
// If we can't fetch models, we'll use the default without model info
|
||||
fmt.Printf("Warning: Could not fetch Cline models: %v\n", err)
|
||||
fmt.Printf("Using default model: %s\n", DefaultClineModelID)
|
||||
return applyDefaultClineModel(ctx, manager, nil)
|
||||
}
|
||||
|
||||
// Check if default model is available
|
||||
modelInfo, err := GetClineModelInfo(DefaultClineModelID, models)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Default model not found: %v\n", err)
|
||||
// Try to use any available model
|
||||
for modelID := range models {
|
||||
fmt.Printf("Using available model: %s\n", modelID)
|
||||
return applyClineModelConfiguration(ctx, manager, modelID, models[modelID])
|
||||
}
|
||||
return fmt.Errorf("no usable Cline models found")
|
||||
}
|
||||
|
||||
// Apply the default model
|
||||
return applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo)
|
||||
}
|
||||
|
||||
// SelectClineModel presents a menu to select a Cline model and applies the configuration.
|
||||
func SelectClineModel(ctx context.Context, manager *task.Manager) error {
|
||||
|
||||
// Fetch models (uses OpenRouter-compatible format)
|
||||
models, err := FetchClineModels(ctx, manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Cline models: %w", err)
|
||||
}
|
||||
|
||||
// Convert to interface map for generic utilities
|
||||
modelMap := ConvertOpenRouterModelsToInterface(models)
|
||||
|
||||
// Get model IDs as a sorted list
|
||||
modelIDs := ConvertModelsMapToSlice(modelMap)
|
||||
|
||||
// Display selection menu
|
||||
selectedModelID, err := DisplayModelSelectionMenu(modelIDs, "Cline")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Get the selected model info
|
||||
modelInfo := models[selectedModelID]
|
||||
|
||||
// Apply the configuration
|
||||
if err := applyClineModelConfiguration(ctx, manager, selectedModelID, modelInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Return to main auth menu after model selection
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// applyClineModelConfiguration applies a Cline model configuration to both Act and Plan modes using UpdateProviderPartial.
|
||||
// Cline uses OpenRouter-compatible model format.
|
||||
func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, modelID string, modelInfo *cline.OpenRouterModelInfo) error {
|
||||
provider := cline.ApiProvider_CLINE
|
||||
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(ctx, manager, provider, updates, true)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// FetchOpenRouterModels fetches available OpenRouter models from Cline Core
|
||||
func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
|
||||
resp, err := manager.GetClient().Models.RefreshOpenRouterModels(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err)
|
||||
}
|
||||
return resp.Models, nil
|
||||
}
|
||||
|
||||
// FetchOpenAiModels fetches available OpenAI models from Cline Core
|
||||
// Takes the API key and returns a list of model IDs
|
||||
func FetchOpenAiModels(ctx context.Context, manager *task.Manager, baseURL, apiKey string) ([]string, error) {
|
||||
req := &cline.OpenAiModelsRequest{
|
||||
BaseUrl: baseURL,
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.RefreshOpenAiModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch OpenAI models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// FetchOllamaModels fetches available Ollama models from Cline Core
|
||||
// Takes the base URL (empty string for default) and returns a list of model IDs
|
||||
func FetchOllamaModels(ctx context.Context, manager *task.Manager, baseURL string) ([]string, error) {
|
||||
req := &cline.StringRequest{
|
||||
Value: baseURL,
|
||||
}
|
||||
|
||||
resp, err := manager.GetClient().Models.GetOllamaModels(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Ollama models: %w", err)
|
||||
}
|
||||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// DisplayModelSelectionMenu shows an interactive menu for selecting a model from a list.
|
||||
// Models are displayed alphabetically. Uses model ID as the option value to avoid
|
||||
// index-based bugs when list order changes.
|
||||
// Returns the selected model ID.
|
||||
func DisplayModelSelectionMenu(models []string, providerName string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available for selection")
|
||||
}
|
||||
|
||||
// Use model ID as the value (not index) to avoid positional coupling bugs
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models))
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Select a %s model", providerName)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title(title).
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// ConvertModelsMapToSlice converts a map of models to a sorted slice of model IDs.
|
||||
// This is useful for displaying models in a consistent order in UI components.
|
||||
func ConvertModelsMapToSlice(models map[string]interface{}) []string {
|
||||
result := make([]string, 0, len(models))
|
||||
for modelID := range models {
|
||||
result = append(result, modelID)
|
||||
}
|
||||
|
||||
// Sort alphabetically for consistent display
|
||||
sort.Strings(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map.
|
||||
// This allows OpenRouter and Cline models to be used with the generic fetching utilities.
|
||||
func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} {
|
||||
result := make(map[string]interface{}, len(models))
|
||||
for k, v := range models {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getTerminalHeight returns the terminal height (rows)
|
||||
func getTerminalHeight() int {
|
||||
_, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || height <= 0 {
|
||||
return 25 // safe fallback for non-TTY or errors
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
// calculateSelectHeight computes appropriate height for Select component
|
||||
// Reserves space for title, search UI, and margins
|
||||
func calculateSelectHeight() int {
|
||||
height := getTerminalHeight()
|
||||
// Reserve ~10 rows for UI chrome (title, search, margins)
|
||||
visibleRows := height - 10
|
||||
// Clamp between 8 (minimum usable) and 25 (maximum before unwieldy)
|
||||
if visibleRows < 8 {
|
||||
return 8
|
||||
}
|
||||
if visibleRows > 25 {
|
||||
return 25
|
||||
}
|
||||
return visibleRows
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/cline/cli/pkg/generated"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// SupportsStaticModelList returns true if the provider has a predefined static model list
|
||||
func SupportsStaticModelList(provider cline.ApiProvider) bool {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this provider has static models defined
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Return true if provider has models and isn't dynamic-only
|
||||
// (Dynamic providers like OpenRouter/OpenAI/Ollama fetch from API)
|
||||
return len(def.Models) > 0 && !def.HasDynamicModels
|
||||
}
|
||||
|
||||
// FetchStaticModels retrieves the static model list for a provider from generated definitions
|
||||
// Returns a sorted list of model IDs and a map of model IDs to their info
|
||||
func FetchStaticModels(provider cline.ApiProvider) ([]string, map[string]generated.ModelInfo, error) {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return nil, nil, fmt.Errorf("unknown provider enum: %v", provider)
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get provider definition: %w", err)
|
||||
}
|
||||
|
||||
if len(def.Models) == 0 {
|
||||
return nil, nil, fmt.Errorf("no models defined for provider %s", providerID)
|
||||
}
|
||||
|
||||
// Extract model IDs and sort them
|
||||
modelIDs := make([]string, 0, len(def.Models))
|
||||
for modelID := range def.Models {
|
||||
modelIDs = append(modelIDs, modelID)
|
||||
}
|
||||
sort.Strings(modelIDs)
|
||||
|
||||
return modelIDs, def.Models, nil
|
||||
}
|
||||
|
||||
// GetDefaultModelForProvider returns the default model ID for a provider if one is defined
|
||||
func GetDefaultModelForProvider(provider cline.ApiProvider) string {
|
||||
providerID := GetProviderIDForEnum(provider)
|
||||
if providerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
def, err := generated.GetProviderDefinition(providerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return def.DefaultModelID
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// BYOProviderOption represents a selectable BYO (bring-your-own) provider option
|
||||
type BYOProviderOption struct {
|
||||
Name string
|
||||
Provider cline.ApiProvider
|
||||
}
|
||||
|
||||
// GetBYOProviderList returns the list of supported BYO providers for CLI configuration.
|
||||
// This list excludes Cline provider which is handled separately.
|
||||
func GetBYOProviderList() []BYOProviderOption {
|
||||
return []BYOProviderOption{
|
||||
{Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC},
|
||||
{Name: "OpenAI", Provider: cline.ApiProvider_OPENAI},
|
||||
{Name: "OpenAI Native", Provider: cline.ApiProvider_OPENAI_NATIVE},
|
||||
{Name: "OpenRouter", Provider: cline.ApiProvider_OPENROUTER},
|
||||
{Name: "X AI (Grok)", Provider: cline.ApiProvider_XAI},
|
||||
{Name: "AWS Bedrock", Provider: cline.ApiProvider_BEDROCK},
|
||||
{Name: "Google Gemini", Provider: cline.ApiProvider_GEMINI},
|
||||
{Name: "Ollama", Provider: cline.ApiProvider_OLLAMA},
|
||||
{Name: "Cerebras", Provider: cline.ApiProvider_CEREBRAS},
|
||||
}
|
||||
}
|
||||
|
||||
// SelectBYOProvider displays a menu for selecting a BYO provider.
|
||||
func SelectBYOProvider() (cline.ApiProvider, error) {
|
||||
providers := GetBYOProviderList()
|
||||
var selectedIndex int
|
||||
|
||||
options := make([]huh.Option[int], len(providers)+1)
|
||||
for i, provider := range providers {
|
||||
options[i] = huh.NewOption(provider.Name, i)
|
||||
}
|
||||
options[len(providers)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select an API provider").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return 0, fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return 0, fmt.Errorf("provider selection cancelled")
|
||||
}
|
||||
|
||||
return providers[selectedIndex].Provider, nil
|
||||
}
|
||||
|
||||
// SupportsBYOModelFetching returns true if the provider supports fetching models dynamically
|
||||
// from a remote API, or if it has a static list of predefined models.
|
||||
// This is used to determine whether to show a model list before prompting for manual entry.
|
||||
func SupportsBYOModelFetching(provider cline.ApiProvider) bool {
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return true
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return true
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return true
|
||||
}
|
||||
|
||||
return SupportsStaticModelList(provider)
|
||||
}
|
||||
|
||||
// GetBYOProviderPlaceholder returns a placeholder model ID for manual entry based on provider.
|
||||
func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "e.g., claude-sonnet-4-5-20250929"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "e.g., gpt-5-2025-08-07"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "e.g., openai/gpt-oss-120b"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "e.g., google/gemini-2.0-flash-exp:free"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "e.g., grok-code-fast-1"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "e.g., anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "e.g., gemini-2.5-pro"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "e.g., qwen3-coder:30b"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "e.g., gpt-oss-120b"
|
||||
default:
|
||||
return "Enter model ID"
|
||||
}
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns field configuration for API key input based on provider.
|
||||
type APIKeyFieldConfig struct {
|
||||
Title string
|
||||
EchoMode huh.EchoMode
|
||||
IsRequired bool
|
||||
}
|
||||
|
||||
// GetBYOAPIKeyFieldConfig returns the configuration for the API key field based on provider.
|
||||
func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig {
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
return APIKeyFieldConfig{
|
||||
Title: "Base URL (optional, press Enter for default)",
|
||||
EchoMode: huh.EchoModeNormal,
|
||||
IsRequired: false,
|
||||
}
|
||||
}
|
||||
|
||||
return APIKeyFieldConfig{
|
||||
Title: "API Key",
|
||||
EchoMode: huh.EchoModePassword,
|
||||
IsRequired: true,
|
||||
}
|
||||
}
|
||||
|
||||
// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama).
|
||||
// For OpenAI Native provider, also prompts for an optional base URL.
|
||||
func PromptForAPIKey(provider cline.ApiProvider) (string, error) {
|
||||
var apiKey string
|
||||
config := GetBYOAPIKeyFieldConfig(provider)
|
||||
|
||||
apiKeyField := huh.NewInput().
|
||||
Title(config.Title).
|
||||
EchoMode(config.EchoMode).
|
||||
Value(&apiKey)
|
||||
|
||||
if config.IsRequired {
|
||||
apiKeyField = apiKeyField.Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("API key cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(apiKeyField))
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// For OpenAI Native provider, also prompt for base URL
|
||||
if provider == cline.ApiProvider_OPENAI_NATIVE {
|
||||
var baseURL string
|
||||
baseURLForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Base URL (optional, for OpenAI-compatible providers)").
|
||||
Placeholder("e.g., https://api.example.com/v1").
|
||||
Value(&baseURL).
|
||||
Description("Press Enter to skip if using standard OpenAI API"),
|
||||
),
|
||||
)
|
||||
|
||||
if err := baseURLForm.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get base URL: %w", err)
|
||||
}
|
||||
|
||||
// TODO - connect baseURL
|
||||
_ = baseURL
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderDisplay represents a configured provider for display purposes
|
||||
type ProviderDisplay struct {
|
||||
Mode string // "Plan" or "Act"
|
||||
Provider cline.ApiProvider // Provider enum
|
||||
ModelID string // Model identifier
|
||||
HasAPIKey bool // Whether an API key is configured (never show actual key)
|
||||
BaseURL string // Base URL for providers like Ollama (can be shown publicly)
|
||||
}
|
||||
|
||||
// ProviderListResult holds the parsed provider configuration from state
|
||||
type ProviderListResult struct {
|
||||
PlanProvider *ProviderDisplay
|
||||
ActProvider *ProviderDisplay
|
||||
apiConfig map[string]interface{} // Store the raw apiConfig for scanning all providers
|
||||
}
|
||||
|
||||
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
|
||||
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
|
||||
}
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Retrieved state, parsing JSON (length: %d)\n", len(stateJSON))
|
||||
}
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Parsed state data with %d keys\n", len(stateData))
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] No apiConfiguration found in state")
|
||||
}
|
||||
return &ProviderListResult{
|
||||
apiConfig: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Found apiConfiguration with %d keys\n", len(apiConfig))
|
||||
}
|
||||
|
||||
// Extract plan mode configuration
|
||||
planProvider := extractProviderFromState(apiConfig, "plan")
|
||||
if global.Config.Verbose && planProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode: provider=%v, model=%s\n", planProvider.Provider, planProvider.ModelID)
|
||||
}
|
||||
|
||||
// Extract act mode configuration
|
||||
actProvider := extractProviderFromState(apiConfig, "act")
|
||||
if global.Config.Verbose && actProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode: provider=%v, model=%s\n", actProvider.Provider, actProvider.ModelID)
|
||||
}
|
||||
|
||||
return &ProviderListResult{
|
||||
PlanProvider: planProvider,
|
||||
ActProvider: actProvider,
|
||||
apiConfig: apiConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllReadyProviders returns all providers that have both a model and API key configured
|
||||
func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
if r.apiConfig == nil {
|
||||
return []*ProviderDisplay{}
|
||||
}
|
||||
|
||||
var readyProviders []*ProviderDisplay
|
||||
seenProviders := make(map[cline.ApiProvider]bool)
|
||||
|
||||
// Check all possible providers
|
||||
allProviders := []cline.ApiProvider{
|
||||
cline.ApiProvider_CLINE,
|
||||
cline.ApiProvider_ANTHROPIC,
|
||||
cline.ApiProvider_OPENAI,
|
||||
cline.ApiProvider_OPENAI_NATIVE,
|
||||
cline.ApiProvider_OPENROUTER,
|
||||
cline.ApiProvider_XAI,
|
||||
cline.ApiProvider_BEDROCK,
|
||||
cline.ApiProvider_GEMINI,
|
||||
cline.ApiProvider_OLLAMA,
|
||||
cline.ApiProvider_CEREBRAS,
|
||||
}
|
||||
|
||||
// Check each provider to see if it's ready to use
|
||||
// We use "plan" mode to check, since both plan and act should have the same providers configured
|
||||
for _, provider := range allProviders {
|
||||
// Skip if we've already seen this provider
|
||||
if seenProviders[provider] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has an API key
|
||||
hasAPIKey := checkAPIKeyExists(r.apiConfig, provider)
|
||||
if !hasAPIKey {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this provider has a model configured
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get base URL for Ollama
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := r.apiConfig["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// This provider is ready to use
|
||||
readyProviders = append(readyProviders, &ProviderDisplay{
|
||||
Mode: "Ready",
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
seenProviders[provider] = true
|
||||
}
|
||||
|
||||
return readyProviders
|
||||
}
|
||||
|
||||
// extractProviderFromState extracts provider configuration for specific plan/act mode
|
||||
func extractProviderFromState(stateData map[string]interface{}, mode string) *ProviderDisplay {
|
||||
// Build key names based on mode
|
||||
providerKey := mode + "ModeApiProvider"
|
||||
|
||||
// Extract provider string from state
|
||||
providerStr, ok := stateData[providerKey].(string)
|
||||
if !ok || providerStr == "" {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] No provider configured for %s mode\n", mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Map provider string to enum
|
||||
provider, ok := mapProviderStringToEnum(providerStr)
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Unknown provider type: %s\n", providerStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get provider-specific model ID
|
||||
modelID := getProviderSpecificModelID(stateData, mode, provider)
|
||||
|
||||
// Check if API key exists
|
||||
hasAPIKey := checkAPIKeyExists(stateData, provider)
|
||||
|
||||
// Get base URL for Ollama (can be shown publicly)
|
||||
baseURL := ""
|
||||
if provider == cline.ApiProvider_OLLAMA {
|
||||
if url, ok := stateData["ollamaBaseUrl"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
return &ProviderDisplay{
|
||||
Mode: capitalizeMode(mode),
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
BaseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// mapProviderStringToEnum converts provider string from state to ApiProvider enum
|
||||
// Returns (provider, ok) where ok is false if the provider is unknown
|
||||
func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
|
||||
// Map string values to enum values
|
||||
switch providerStr {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, true
|
||||
case "openai":
|
||||
return cline.ApiProvider_OPENAI, true
|
||||
case "openai-native":
|
||||
return cline.ApiProvider_OPENAI_NATIVE, true
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, true
|
||||
case "xai":
|
||||
return cline.ApiProvider_XAI, true
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, true
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, true
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, true
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, true
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, true
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false
|
||||
}
|
||||
}
|
||||
|
||||
// GetProviderIDForEnum converts a provider enum to the provider ID string
|
||||
// This is the inverse of mapProviderStringToEnum and is used for provider definitions
|
||||
func GetProviderIDForEnum(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "openai"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "openai-native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "openrouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "xai"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "cline"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// getProviderSpecificModelID gets the provider-specific model ID field from state
|
||||
func getProviderSpecificModelID(stateData map[string]interface{}, mode string, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, mode)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Error getting model ID field name: %v\n", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("[DEBUG] Looking for model ID in key: %s\n", modelKey)
|
||||
}
|
||||
|
||||
// Extract model ID from state
|
||||
modelID, _ := stateData[modelKey].(string)
|
||||
return modelID
|
||||
}
|
||||
|
||||
// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// Get field mapping from centralized function
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
keyField := fields.APIKeyField
|
||||
|
||||
// Check if the key exists and is not empty
|
||||
if value, ok := stateData[keyField]; ok {
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// capitalizeMode capitalizes the mode string for display
|
||||
func capitalizeMode(mode string) string {
|
||||
if len(mode) == 0 {
|
||||
return mode
|
||||
}
|
||||
return strings.ToUpper(mode[:1]) + mode[1:]
|
||||
}
|
||||
|
||||
// getProviderDisplayName returns a user-friendly name for the provider
|
||||
func getProviderDisplayName(provider cline.ApiProvider) string {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return "Anthropic"
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return "OpenAI"
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return "OpenAI Native"
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return "OpenRouter"
|
||||
case cline.ApiProvider_XAI:
|
||||
return "X AI (Grok)"
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return "AWS Bedrock"
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return "Google Gemini"
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return "Ollama"
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return "Cerebras"
|
||||
case cline.ApiProvider_CLINE:
|
||||
return "Cline (Official)"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatProviderList formats the complete provider list for console display
|
||||
// This now shows ALL providers that have both a model and API key configured
|
||||
func FormatProviderList(result *ProviderListResult) string {
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("\n=== Configured API Providers ===\n\n")
|
||||
|
||||
// Get the currently active provider
|
||||
var activeProvider cline.ApiProvider
|
||||
var activeProviderSet bool
|
||||
if result.ActProvider != nil {
|
||||
activeProvider = result.ActProvider.Provider
|
||||
activeProviderSet = true
|
||||
}
|
||||
|
||||
// Get all ready-to-use providers (those with both API key and model configured)
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
if len(readyProviders) == 0 {
|
||||
output.WriteString(" No providers ready to use.\n")
|
||||
output.WriteString(" A provider is ready when it has both a model and API key configured.\n")
|
||||
output.WriteString(" Use 'Configure a new provider' to configure one.\n\n")
|
||||
} else {
|
||||
//output.WriteString(fmt.Sprintf(" %d provider(s) ready to use:\n\n", len(readyProviders)))
|
||||
|
||||
for _, display := range readyProviders {
|
||||
// Check if this is the active provider
|
||||
isActive := activeProviderSet && display.Provider == activeProvider
|
||||
|
||||
if isActive {
|
||||
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", getProviderDisplayName(display.Provider)))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf(" • %s\n", getProviderDisplayName(display.Provider)))
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID))
|
||||
|
||||
// Show status based on provider type
|
||||
if display.Provider == cline.ApiProvider_OLLAMA {
|
||||
if display.BaseURL != "" {
|
||||
output.WriteString(fmt.Sprintf(" Base URL: %s\n", display.BaseURL))
|
||||
} else {
|
||||
output.WriteString(" Base URL: (default)\n")
|
||||
}
|
||||
} else if display.Provider == cline.ApiProvider_CLINE {
|
||||
output.WriteString(" Status: Authenticated\n")
|
||||
} else {
|
||||
output.WriteString(" API Key: Configured\n")
|
||||
}
|
||||
|
||||
output.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteString("================================\n")
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// DetectAllConfiguredProviders scans the state to find all providers that have API keys configured.
|
||||
// This allows switching between multiple providers even when only one is currently active.
|
||||
func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([]cline.ApiProvider, error) {
|
||||
verboseLog("[DEBUG] Detecting all configured providers...")
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
stateJSON := state.StateJson
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
verboseLog("[DEBUG] No apiConfiguration found in state")
|
||||
verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData))
|
||||
return []cline.ApiProvider{}, nil
|
||||
}
|
||||
|
||||
verboseLog("[DEBUG] apiConfiguration keys: %v", getMapKeys(apiConfig))
|
||||
|
||||
var configuredProviders []cline.ApiProvider
|
||||
|
||||
// Check for Cline provider (uses authentication instead of API key)
|
||||
if IsAuthenticated(ctx) {
|
||||
configuredProviders = append(configuredProviders, cline.ApiProvider_CLINE)
|
||||
verboseLog("[DEBUG] Cline provider is authenticated")
|
||||
}
|
||||
|
||||
// Check each BYO provider for API key presence
|
||||
providersToCheck := []struct {
|
||||
provider cline.ApiProvider
|
||||
keyField string
|
||||
}{
|
||||
{cline.ApiProvider_ANTHROPIC, "apiKey"},
|
||||
{cline.ApiProvider_OPENAI, "openAiApiKey"},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"},
|
||||
{cline.ApiProvider_OPENROUTER, "openRouterApiKey"},
|
||||
{cline.ApiProvider_XAI, "xaiApiKey"},
|
||||
{cline.ApiProvider_BEDROCK, "awsAccessKey"},
|
||||
{cline.ApiProvider_GEMINI, "geminiApiKey"},
|
||||
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", getProviderDisplayName(providerCheck.provider), providerCheck.keyField)
|
||||
if value, ok := apiConfig[providerCheck.keyField]; ok {
|
||||
verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "")
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
configuredProviders = append(configuredProviders, providerCheck.provider)
|
||||
verboseLog("[DEBUG] ✓ Provider %s is configured", getProviderDisplayName(providerCheck.provider))
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", providerCheck.keyField)
|
||||
}
|
||||
}
|
||||
|
||||
verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders))
|
||||
for _, p := range configuredProviders {
|
||||
verboseLog("[DEBUG] - %s", getProviderDisplayName(p))
|
||||
}
|
||||
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// getMapKeys returns the keys of a map for debugging
|
||||
func getMapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// updateApiConfigurationPartial is a helper that calls the gRPC method with optional verbose logging.
|
||||
// This replaces the Manager.UpdateApiConfigurationPartial method to keep auth-specific code in the auth package.
|
||||
func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, request *cline.UpdateApiConfigurationPartialRequest) error {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Updating API configuration (partial)")
|
||||
if request.UpdateMask != nil && len(request.UpdateMask.Paths) > 0 {
|
||||
fmt.Printf("[DEBUG] Field mask paths: %v\n", request.UpdateMask.Paths)
|
||||
}
|
||||
if request.ApiConfiguration != nil {
|
||||
apiConfig := request.ApiConfiguration
|
||||
if apiConfig.PlanModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Plan mode provider: %s\n", *apiConfig.PlanModeApiProvider)
|
||||
}
|
||||
if apiConfig.ActModeApiProvider != nil {
|
||||
fmt.Printf("[DEBUG] Act mode provider: %s\n", *apiConfig.ActModeApiProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the Models service to update API configuration
|
||||
_, err := manager.GetClient().Models.UpdateApiConfigurationPartial(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update API configuration (partial): %w", err)
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] API configuration updated successfully (partial)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderFields defines all the field names associated with a specific provider
|
||||
type ProviderFields struct {
|
||||
APIKeyField string // API key field name (e.g., "apiKey", "openAiApiKey")
|
||||
PlanModeModelIDField string // Plan mode model ID field (e.g., "planModeApiModelId")
|
||||
ActModeModelIDField string // Act mode model ID field (e.g., "actModeApiModelId")
|
||||
PlanModeModelInfoField string // Plan mode model info field (optional, empty if not applicable)
|
||||
ActModeModelInfoField string // Act mode model info field (optional, empty if not applicable)
|
||||
// Provider-specific additional model ID fields
|
||||
PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId"
|
||||
ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId"
|
||||
}
|
||||
|
||||
// GetProviderFields returns the field mapping for a given provider
|
||||
func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
switch provider {
|
||||
case cline.ApiProvider_ANTHROPIC:
|
||||
return ProviderFields{
|
||||
APIKeyField: "apiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenAiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openRouterApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_XAI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "xaiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return ProviderFields{
|
||||
APIKeyField: "awsAccessKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeAwsBedrockCustomModelBaseId",
|
||||
ActModeProviderSpecificModelIDField: "actModeAwsBedrockCustomModelBaseId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_GEMINI:
|
||||
return ProviderFields{
|
||||
APIKeyField: "geminiApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI_NATIVE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "openAiNativeApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
return ProviderFields{
|
||||
APIKeyField: "ollamaBaseUrl",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOllamaModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOllamaModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CEREBRAS:
|
||||
return ProviderFields{
|
||||
APIKeyField: "cerebrasApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
}, nil
|
||||
|
||||
case cline.ApiProvider_CLINE:
|
||||
return ProviderFields{
|
||||
APIKeyField: "clineApiKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
|
||||
ActModeModelInfoField: "actModeOpenRouterModelInfo",
|
||||
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
|
||||
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderUpdatesPartial defines optional fields for partial provider updates
|
||||
// Uses pointers to distinguish between "not provided" and "set to empty"
|
||||
type ProviderUpdatesPartial struct {
|
||||
ModelID *string // New model ID (optional)
|
||||
APIKey *string // New API key (optional)
|
||||
ModelInfo interface{} // New model info (optional, provider-specific)
|
||||
}
|
||||
|
||||
// GetModelIDFieldName returns the appropriate model ID field name for a provider and mode.
|
||||
// This helper centralizes the logic for determining whether to use provider-specific
|
||||
// or generic model ID fields.
|
||||
func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error) {
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if mode == "plan" {
|
||||
// Use provider-specific field if available, otherwise use generic field
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
return fields.PlanModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.PlanModeModelIDField, nil
|
||||
}
|
||||
|
||||
// Act mode
|
||||
if fields.ActModeProviderSpecificModelIDField != "" {
|
||||
return fields.ActModeProviderSpecificModelIDField, nil
|
||||
}
|
||||
return fields.ActModeModelIDField, nil
|
||||
}
|
||||
|
||||
// buildProviderFieldMask builds a list of camelCase field paths for the field mask.
|
||||
// When includeProviderEnums is true, the provider enum fields are included (for setting active provider).
|
||||
// When false, only the data fields are included (for configuring without activating).
|
||||
func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeProviderEnums bool) []string {
|
||||
var fieldPaths []string
|
||||
|
||||
// Include provider enums if requested (used when setting active provider)
|
||||
if includeProviderEnums {
|
||||
fieldPaths = append(fieldPaths, "planModeApiProvider", "actModeApiProvider")
|
||||
}
|
||||
|
||||
// Add API key field if requested
|
||||
if includeAPIKey {
|
||||
fieldPaths = append(fieldPaths, fields.APIKeyField)
|
||||
// Special case: Bedrock also needs secret key
|
||||
if fields.APIKeyField == "awsAccessKey" {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
}
|
||||
|
||||
// Add model ID fields if requested
|
||||
if includeModelID {
|
||||
// Only include provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
// Provider has specific fields - use ONLY those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeProviderSpecificModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeProviderSpecificModelIDField)
|
||||
} else {
|
||||
// Provider uses generic fields - update those
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelIDField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelIDField)
|
||||
}
|
||||
}
|
||||
|
||||
// Add model info fields if requested and applicable
|
||||
if includeModelInfo && fields.PlanModeModelInfoField != "" {
|
||||
fieldPaths = append(fieldPaths, fields.PlanModeModelInfoField)
|
||||
fieldPaths = append(fieldPaths, fields.ActModeModelInfoField)
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
|
||||
// setAPIKeyField sets the appropriate API key field in the config based on the field name
|
||||
func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "apiKey":
|
||||
apiConfig.ApiKey = value
|
||||
case "openAiApiKey":
|
||||
apiConfig.OpenAiApiKey = value
|
||||
case "openAiNativeApiKey":
|
||||
apiConfig.OpenAiNativeApiKey = value
|
||||
case "openRouterApiKey":
|
||||
apiConfig.OpenRouterApiKey = value
|
||||
case "xaiApiKey":
|
||||
apiConfig.XaiApiKey = value
|
||||
case "awsAccessKey":
|
||||
apiConfig.AwsAccessKey = value
|
||||
case "geminiApiKey":
|
||||
apiConfig.GeminiApiKey = value
|
||||
case "ollamaBaseUrl":
|
||||
apiConfig.OllamaBaseUrl = value
|
||||
case "cerebrasApiKey":
|
||||
apiConfig.CerebrasApiKey = value
|
||||
case "clineApiKey":
|
||||
apiConfig.ClineApiKey = value
|
||||
}
|
||||
}
|
||||
|
||||
// setProviderSpecificModelID sets the appropriate provider-specific model ID fields when possible
|
||||
func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
|
||||
switch fieldName {
|
||||
case "planModeOpenAiModelId":
|
||||
apiConfig.PlanModeOpenAiModelId = value
|
||||
apiConfig.ActModeOpenAiModelId = value
|
||||
case "planModeOpenRouterModelId":
|
||||
apiConfig.PlanModeOpenRouterModelId = value
|
||||
apiConfig.ActModeOpenRouterModelId = value
|
||||
case "planModeOllamaModelId":
|
||||
apiConfig.PlanModeOllamaModelId = value
|
||||
apiConfig.ActModeOllamaModelId = value
|
||||
case "planModeAwsBedrockCustomModelBaseId":
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = value
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = value
|
||||
}
|
||||
}
|
||||
|
||||
// AddProviderPartial configures a new provider with all necessary fields using partial updates.
|
||||
func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, modelInfo interface{}) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the relevant provider fields set
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set API key field
|
||||
if apiKey != "" || fields.APIKeyField != "ollamaBaseUrl" {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, proto.String(apiKey))
|
||||
}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
|
||||
// Set provider-specific model ID fields if applicable
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, proto.String(modelID))
|
||||
}
|
||||
|
||||
// Set model info if applicable and provided
|
||||
if fields.PlanModeModelInfoField != "" && modelInfo != nil {
|
||||
if openRouterInfo, ok := modelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask including all fields we're setting (without provider enums)
|
||||
includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil
|
||||
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, false)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProviderPartial updates specific fields for an existing provider using partial updates.
|
||||
// If setAsActive is true, this will also set the provider as the active provider for both Plan and Act modes.
|
||||
func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, updates ProviderUpdatesPartial, setAsActive bool) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build a ModelsApiConfiguration with only the fields being updated
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set provider enum for BOTH Plan and Act modes if setAsActive is true
|
||||
if setAsActive {
|
||||
apiConfig.PlanModeApiProvider = &provider
|
||||
apiConfig.ActModeApiProvider = &provider
|
||||
}
|
||||
|
||||
// Track what we're updating for field mask
|
||||
includeAPIKey := updates.APIKey != nil
|
||||
includeModelID := updates.ModelID != nil
|
||||
includeModelInfo := updates.ModelInfo != nil && fields.PlanModeModelInfoField != ""
|
||||
|
||||
// Update API key if provided
|
||||
if updates.APIKey != nil {
|
||||
setAPIKeyField(apiConfig, fields.APIKeyField, updates.APIKey)
|
||||
}
|
||||
|
||||
// Update model ID if provided
|
||||
if updates.ModelID != nil {
|
||||
// Only set provider-specific fields if they exist, otherwise use generic fields
|
||||
if fields.PlanModeProviderSpecificModelIDField != "" {
|
||||
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, updates.ModelID)
|
||||
} else {
|
||||
// Provider uses generic fields - set those
|
||||
apiConfig.PlanModeApiModelId = updates.ModelID
|
||||
apiConfig.ActModeApiModelId = updates.ModelID
|
||||
}
|
||||
}
|
||||
|
||||
// Update model info if provided
|
||||
if updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" {
|
||||
if openRouterInfo, ok := updates.ModelInfo.(*cline.OpenRouterModelInfo); ok {
|
||||
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
|
||||
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Build field mask for only the fields being updated
|
||||
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, setAsActive)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProviderPartial removes a provider by clearing its API key using partial updates
|
||||
func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get field mapping for this provider
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build an EMPTY ModelsApiConfiguration (or one with empty API key field)
|
||||
// Fields in the mask without values will be cleared
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Build field mask with only the API key field(s)
|
||||
// For Bedrock, include both access key and secret key
|
||||
fieldPaths := []string{fields.APIKeyField}
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
fieldPaths = append(fieldPaths, "awsSecretKey")
|
||||
}
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update (clearing API key by including in mask without value)
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to update API configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BedrockOptionalFields holds optional configuration fields for AWS Bedrock
|
||||
type BedrockOptionalFields struct {
|
||||
SessionToken *string // Optional: AWS session token for temporary credentials
|
||||
Region *string // Optional: AWS region
|
||||
UseCrossRegionInference *bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference *bool // Optional: Use global inference endpoint
|
||||
UsePromptCache *bool // Optional: Enable prompt caching
|
||||
Authentication *string // Optional: Authentication method
|
||||
UseProfile *bool // Optional: Use AWS profile
|
||||
Profile *string // Optional: AWS profile name
|
||||
Endpoint *string // Optional: Custom endpoint URL
|
||||
}
|
||||
|
||||
// setBedrockOptionalFields sets optional Bedrock-specific fields in the API configuration
|
||||
func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *BedrockOptionalFields) {
|
||||
if fields == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
apiConfig.AwsSessionToken = fields.SessionToken
|
||||
}
|
||||
if fields.Region != nil {
|
||||
apiConfig.AwsRegion = fields.Region
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
apiConfig.AwsUseCrossRegionInference = fields.UseCrossRegionInference
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
apiConfig.AwsUseGlobalInference = fields.UseGlobalInference
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
apiConfig.AwsBedrockUsePromptCache = fields.UsePromptCache
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
apiConfig.AwsAuthentication = fields.Authentication
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
apiConfig.AwsUseProfile = fields.UseProfile
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
apiConfig.AwsProfile = fields.Profile
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
apiConfig.AwsBedrockEndpoint = fields.Endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// buildBedrockOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
|
||||
func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fieldPaths []string
|
||||
|
||||
if fields.SessionToken != nil {
|
||||
fieldPaths = append(fieldPaths, "awsSessionToken")
|
||||
}
|
||||
if fields.Region != nil {
|
||||
fieldPaths = append(fieldPaths, "awsRegion")
|
||||
}
|
||||
if fields.UseCrossRegionInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseCrossRegionInference")
|
||||
}
|
||||
if fields.UseGlobalInference != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseGlobalInference")
|
||||
}
|
||||
if fields.UsePromptCache != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockUsePromptCache")
|
||||
}
|
||||
if fields.Authentication != nil {
|
||||
fieldPaths = append(fieldPaths, "awsAuthentication")
|
||||
}
|
||||
if fields.UseProfile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsUseProfile")
|
||||
}
|
||||
if fields.Profile != nil {
|
||||
fieldPaths = append(fieldPaths, "awsProfile")
|
||||
}
|
||||
if fields.Endpoint != nil {
|
||||
fieldPaths = append(fieldPaths, "awsBedrockEndpoint")
|
||||
}
|
||||
|
||||
return fieldPaths
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ProviderWizard handles the interactive provider configuration process
|
||||
type ProviderWizard struct {
|
||||
ctx context.Context
|
||||
manager *task.Manager
|
||||
}
|
||||
|
||||
// NewProviderWizard prepares a new provider configuration wizard
|
||||
func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) {
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure Cline Core instance: %w", err)
|
||||
}
|
||||
|
||||
manager, err := task.NewManagerForDefault(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create task manager: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderWizard{
|
||||
ctx: ctx,
|
||||
manager: manager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// showMainMenu displays the main provider configuration menu
|
||||
func (pw *ProviderWizard) showMainMenu() (string, error) {
|
||||
var action string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Configure a new provider", "add"),
|
||||
huh.NewOption("Change model for API provider", "change-model"),
|
||||
huh.NewOption("Remove a provider", "remove"),
|
||||
huh.NewOption("List configured providers", "list"),
|
||||
huh.NewOption("Return to main auth menu", "back"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to get menu choice: %w", err)
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
|
||||
// Run runs the provider configuration wizard
|
||||
func (pw *ProviderWizard) Run() error {
|
||||
|
||||
for {
|
||||
action, err := pw.showMainMenu()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if err := pw.handleAddProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "change-model":
|
||||
if err := pw.handleChangeModel(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "remove":
|
||||
if err := pw.handleRemoveProvider(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "list":
|
||||
if err := pw.handleListProviders(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "back":
|
||||
// Return to main auth menu
|
||||
return HandleAuthMenuNoArgs(pw.ctx)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// "Add a new provider" > handleAddProvider
|
||||
func (pw *ProviderWizard) handleAddProvider() error {
|
||||
// Step 1: Select provider
|
||||
provider, err := SelectBYOProvider()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "cancelled") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("provider selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Special handling for Bedrock provider
|
||||
if provider == cline.ApiProvider_BEDROCK {
|
||||
return pw.handleAddBedrockProvider()
|
||||
}
|
||||
|
||||
// Step 3: Get API key first (for non-Bedrock providers)
|
||||
apiKey, err := PromptForAPIKey(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Try to fetch models and let user select (with fallback to manual entry for providers that don't support fetch)
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Apply configuration using AddProviderPartial
|
||||
if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAddBedrockProvider handles the special case of adding Bedrock provider with its multi-field form
|
||||
func (pw *ProviderWizard) handleAddBedrockProvider() error {
|
||||
// Step 1: Get Bedrock configuration (all credentials and optional fields)
|
||||
config, err := PromptForBedrockConfig(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "user declined profile authentication") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Select model
|
||||
modelID, modelInfo, err := pw.selectModel(cline.ApiProvider_BEDROCK, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Apply Bedrock configuration
|
||||
if err := ApplyBedrockConfig(pw.ctx, pw.manager, config, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to save Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Bedrock provider configured successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleListProviders retrieves and displays configured providers
|
||||
func (pw *ProviderWizard) handleListProviders() error {
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
output := FormatProviderList(result)
|
||||
fmt.Println(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectModel attempts to fetch available models and let user select, or falls back to manual entry
|
||||
func (pw *ProviderWizard) selectModel(provider cline.ApiProvider, apiKey string) (string, interface{}, error) {
|
||||
// For providers that support model fetching, try to fetch and display models
|
||||
canFetchModels := pw.supportsModelFetching(provider)
|
||||
|
||||
if canFetchModels {
|
||||
fmt.Println("Fetching available models...")
|
||||
models, modelInfoMap, err := pw.fetchModelsForProvider(provider, apiKey)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("\n⚠ Unable to fetch model list from the provider. Please enter the model ID manually instead.")
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf(" Error details: %v\n", err)
|
||||
}
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
fmt.Println("\n⚠ No models found from the provider. Please enter the model ID manually instead.")
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Let user select from available models (includes manual entry option)
|
||||
modelID, err := pw.selectFromAvailableModels(models)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if user chose manual entry
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
if modelID == manualEntryKey {
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// Get the model info for the selected model
|
||||
var modelInfo interface{}
|
||||
if modelInfoMap != nil {
|
||||
modelInfo = modelInfoMap[modelID]
|
||||
}
|
||||
|
||||
return modelID, modelInfo, nil
|
||||
}
|
||||
|
||||
// For providers without model fetching support, use manual entry
|
||||
return pw.manualModelEntry(provider)
|
||||
}
|
||||
|
||||
// supportsModelFetching returns true if the provider supports fetching models
|
||||
func (pw *ProviderWizard) supportsModelFetching(provider cline.ApiProvider) bool {
|
||||
return SupportsBYOModelFetching(provider)
|
||||
}
|
||||
|
||||
// fetchModelsForProvider fetches models for a given provider
|
||||
// Supports both dynamic API fetching (OpenRouter, OpenAI, Ollama) and static model lists (Anthropic, Bedrock, Gemini, X AI)
|
||||
func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, apiKey string) ([]string, map[string]interface{}, error) {
|
||||
// Try dynamic/remote model fetching first
|
||||
switch provider {
|
||||
case cline.ApiProvider_OPENROUTER:
|
||||
models, err := FetchOpenRouterModels(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
interfaceMap := ConvertOpenRouterModelsToInterface(models)
|
||||
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
|
||||
|
||||
case cline.ApiProvider_OPENAI:
|
||||
// For OpenAI, we need to pass the base URL and API key
|
||||
baseURL := "https://api.openai.com/v1" // Default OpenAI API base URL
|
||||
modelIDs, err := FetchOpenAiModels(pw.ctx, pw.manager, baseURL, apiKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// OpenAI returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
|
||||
case cline.ApiProvider_OLLAMA:
|
||||
// For Ollama, apiKey actually contains the base URL (or empty for default)
|
||||
baseURL := apiKey // The "API key" field for Ollama is actually the base URL
|
||||
modelIDs, err := FetchOllamaModels(pw.ctx, pw.manager, baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Ollama returns just model IDs without additional info, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
}
|
||||
|
||||
// Fall back to static models for providers that don't support dynamic fetching
|
||||
if SupportsStaticModelList(provider) {
|
||||
modelIDs, _, err := FetchStaticModels(provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Static models don't have detailed info maps for now, so modelInfo map is nil
|
||||
return modelIDs, nil, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("model fetching not supported for provider: %v", provider)
|
||||
}
|
||||
|
||||
// selectFromAvailableModels displays available models and lets user select one.
|
||||
// Includes an option to enter a model ID manually in case the desired model isn't listed.
|
||||
func (pw *ProviderWizard) selectFromAvailableModels(models []string) (string, error) {
|
||||
if len(models) == 0 {
|
||||
return "", fmt.Errorf("no models available")
|
||||
}
|
||||
|
||||
// Add a special "manual entry" option at the end
|
||||
const manualEntryKey = "__MANUAL_ENTRY__"
|
||||
|
||||
// Use model ID as the value (not index)
|
||||
var selectedModel string
|
||||
options := make([]huh.Option[string], len(models)+1)
|
||||
for i, model := range models {
|
||||
options[i] = huh.NewOption(model, model)
|
||||
}
|
||||
// Add manual entry option at the end
|
||||
options[len(models)] = huh.NewOption("Enter model ID manually...", manualEntryKey)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Select a model").
|
||||
Options(options...).
|
||||
Height(calculateSelectHeight()).
|
||||
Filtering(true).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to select model: %w", err)
|
||||
}
|
||||
|
||||
// If user selected manual entry, return special key to trigger manual input
|
||||
if selectedModel == manualEntryKey {
|
||||
return manualEntryKey, nil
|
||||
}
|
||||
|
||||
return selectedModel, nil
|
||||
}
|
||||
|
||||
// manualModelEntry prompts user to manually enter a model ID.
|
||||
// Returns the model ID and an error. The modelInfo is always nil for manual entry.
|
||||
func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string, interface{}, error) {
|
||||
var modelID string
|
||||
modelPlaceholder := GetBYOProviderPlaceholder(provider)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Model ID").
|
||||
Placeholder(modelPlaceholder).
|
||||
Value(&modelID).
|
||||
Validate(func(s string) error {
|
||||
// Trim whitespace and validate
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("model ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get model ID: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from the final value
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
|
||||
// modelInfo is always nil for manual entry
|
||||
return modelID, nil, nil
|
||||
}
|
||||
|
||||
// handleChangeModel allows changing the model for any configured provider
|
||||
func (pw *ProviderWizard) handleChangeModel() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all configured providers with models
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (it has its own model changer in the main menu)
|
||||
var configurableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
configurableProviders = append(configurableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are any configurable providers
|
||||
if len(configurableProviders) == 0 {
|
||||
fmt.Println("\nNo configurable providers found.")
|
||||
fmt.Println("Note: Cline provider has its own model selection in the main menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Let user select which provider to change the model for
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(configurableProviders)+1)
|
||||
for i, providerDisplay := range configurableProviders {
|
||||
displayName := fmt.Sprintf("%s (current: %s)",
|
||||
getProviderDisplayName(providerDisplay.Provider),
|
||||
providerDisplay.ModelID)
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
options[len(configurableProviders)] = huh.NewOption("(Cancel)", -1)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to change model for").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
if selectedIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
selectedProvider := configurableProviders[selectedIndex]
|
||||
provider := selectedProvider.Provider
|
||||
|
||||
fmt.Printf("\nChanging model for %s\n", getProviderDisplayName(provider))
|
||||
fmt.Printf("Current model: %s\n\n", selectedProvider.ModelID)
|
||||
|
||||
// Step 5: Retrieve API key if needed for model fetching
|
||||
var apiKey string
|
||||
if pw.supportsModelFetching(provider) {
|
||||
// For providers that support fetching, we need to retrieve the API key from state
|
||||
state, err := pw.manager.GetClient().State.GetLatestState(pw.ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
apiKey = getProviderAPIKeyFromState(apiConfig, provider)
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("no API key found for provider %s", getProviderDisplayName(provider))
|
||||
}
|
||||
}
|
||||
|
||||
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model selection failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Apply the model change (for both Plan and Act modes)
|
||||
if err := pw.applyModelChange(provider, modelID, modelInfo); err != nil {
|
||||
return fmt.Errorf("failed to apply model change: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Model changed successfully to: %s\n", modelID)
|
||||
fmt.Println(" (Applied to both Plan and Act modes)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyModelChange applies a model change for both Plan and Act modes using UpdateProviderPartial
|
||||
func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID string, modelInfo interface{}) error {
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, false)
|
||||
}
|
||||
|
||||
// SwitchToBYOProvider switches to a BYO provider that's already configured.
|
||||
// It retrieves the existing model configuration and sets it as the active provider for both Plan and Act modes.
|
||||
func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
|
||||
// Get the current state to retrieve the model ID and model info for this provider
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("no API configuration found in state")
|
||||
}
|
||||
|
||||
// Get the model ID for the selected provider
|
||||
modelID := getProviderModelIDFromState(apiConfig, provider)
|
||||
if modelID == "" {
|
||||
return fmt.Errorf("no model configured for provider %s", getProviderDisplayName(provider))
|
||||
}
|
||||
|
||||
// Get model info if available (for OpenRouter/Cline)
|
||||
var modelInfo interface{}
|
||||
if provider == cline.ApiProvider_OPENROUTER || provider == cline.ApiProvider_CLINE {
|
||||
if modelInfoData, ok := apiConfig["planModeOpenRouterModelInfo"].(map[string]interface{}); ok {
|
||||
modelInfo = convertMapToOpenRouterModelInfo(modelInfoData)
|
||||
}
|
||||
}
|
||||
|
||||
// Use UpdateProviderPartial to switch to this provider
|
||||
updates := ProviderUpdatesPartial{
|
||||
ModelID: &modelID,
|
||||
ModelInfo: modelInfo,
|
||||
}
|
||||
|
||||
if err := UpdateProviderPartial(ctx, manager, provider, updates, true); err != nil {
|
||||
return fmt.Errorf("failed to switch provider: %w", err)
|
||||
}
|
||||
|
||||
verboseLog("✓ Switched to %s\n", getProviderDisplayName(provider))
|
||||
verboseLog(" Using model: %s\n", modelID)
|
||||
|
||||
return HandleAuthMenuNoArgs(ctx)
|
||||
}
|
||||
|
||||
// getProviderModelIDFromState retrieves the model ID for a specific provider from state
|
||||
func getProviderModelIDFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
modelKey, err := GetModelIDFieldName(provider, "plan")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if modelID, ok := stateData[modelKey].(string); ok {
|
||||
return modelID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if apiKey, ok := stateData[fields.APIKeyField].(string); ok {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// convertMapToOpenRouterModelInfo converts a map to OpenRouterModelInfo
|
||||
func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRouterModelInfo {
|
||||
info := &cline.OpenRouterModelInfo{}
|
||||
|
||||
if val, ok := data["description"].(string); ok {
|
||||
info.Description = &val
|
||||
}
|
||||
if val, ok := data["contextWindow"].(float64); ok {
|
||||
contextWindow := int64(val)
|
||||
info.ContextWindow = &contextWindow
|
||||
}
|
||||
if val, ok := data["maxTokens"].(float64); ok {
|
||||
maxTokens := int64(val)
|
||||
info.MaxTokens = &maxTokens
|
||||
}
|
||||
if val, ok := data["inputPrice"].(float64); ok {
|
||||
info.InputPrice = &val
|
||||
}
|
||||
if val, ok := data["outputPrice"].(float64); ok {
|
||||
info.OutputPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheWritesPrice"].(float64); ok {
|
||||
info.CacheWritesPrice = &val
|
||||
}
|
||||
if val, ok := data["cacheReadsPrice"].(float64); ok {
|
||||
info.CacheReadsPrice = &val
|
||||
}
|
||||
if val, ok := data["supportsImages"].(bool); ok {
|
||||
info.SupportsImages = &val
|
||||
}
|
||||
if val, ok := data["supportsPromptCache"].(bool); ok {
|
||||
info.SupportsPromptCache = val
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// handleRemoveProvider allows removing a configured provider by clearing its API key
|
||||
func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all ready providers
|
||||
readyProviders := result.GetAllReadyProviders()
|
||||
|
||||
// Filter out Cline provider (uses account auth, not API keys)
|
||||
var removableProviders []*ProviderDisplay
|
||||
for _, provider := range readyProviders {
|
||||
if provider.Provider != cline.ApiProvider_CLINE {
|
||||
removableProviders = append(removableProviders, provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if there are providers to remove
|
||||
if len(removableProviders) == 0 {
|
||||
fmt.Println("\nNo providers available to remove.")
|
||||
fmt.Println("Note: Cline provider cannot be removed via this menu.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 4: Display selection menu
|
||||
var selectedIndex int
|
||||
options := make([]huh.Option[int], len(removableProviders))
|
||||
for i, provider := range removableProviders {
|
||||
// Mark active provider
|
||||
displayName := getProviderDisplayName(provider.Provider)
|
||||
if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider {
|
||||
displayName += " (ACTIVE)"
|
||||
}
|
||||
options[i] = huh.NewOption(displayName, i)
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[int]().
|
||||
Title("Select provider to remove").
|
||||
Options(options...).
|
||||
Value(&selectedIndex),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("failed to select provider: %w", err)
|
||||
}
|
||||
|
||||
selectedProvider := removableProviders[selectedIndex]
|
||||
|
||||
// Step 5: Check if trying to remove the active provider
|
||||
if result.ActProvider != nil && selectedProvider.Provider == result.ActProvider.Provider {
|
||||
fmt.Printf("\nCannot remove %s because it is currently active.\n", getProviderDisplayName(selectedProvider.Provider))
|
||||
fmt.Println("Please switch to a different provider first, then try again.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 6: Confirm removal
|
||||
var confirm bool
|
||||
confirmForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(fmt.Sprintf("Are you sure you want to remove %s?", getProviderDisplayName(selectedProvider.Provider))).
|
||||
Description("This will clear the API key but preserve the model configuration.").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
|
||||
if err := confirmForm.Run(); err != nil {
|
||||
return fmt.Errorf("failed to get confirmation: %w", err)
|
||||
}
|
||||
|
||||
if !confirm {
|
||||
fmt.Println("Removal cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 7: Clear the API key for the selected provider
|
||||
if err := pw.clearProviderAPIKey(selectedProvider.Provider); err != nil {
|
||||
return fmt.Errorf("failed to remove provider: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ %s removed successfully\n", getProviderDisplayName(selectedProvider.Provider))
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearProviderAPIKey clears the API key field for a specific provider using RemoveProviderPartial
|
||||
func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error {
|
||||
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
)
|
||||
|
||||
// BedrockConfig holds all AWS Bedrock-specific configuration fields
|
||||
type BedrockConfig struct {
|
||||
// Profile authentication fields
|
||||
UseProfile bool // Always true for successful config
|
||||
Profile string // Optional: AWS profile name (empty = default)
|
||||
Region string // Required: AWS region
|
||||
Endpoint string // Optional: Custom VPC endpoint URL
|
||||
|
||||
// Optional features
|
||||
UseCrossRegionInference bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference bool // Optional: Use global inference endpoint
|
||||
UsePromptCache bool // Optional: Enable prompt caching
|
||||
|
||||
// Authentication method (always "profile")
|
||||
Authentication string // Always set to "profile"
|
||||
|
||||
// Legacy fields (no longer used in profile-only flow)
|
||||
AccessKey string // No longer used
|
||||
SecretKey string // No longer used
|
||||
SessionToken string // No longer used
|
||||
}
|
||||
|
||||
// PromptForBedrockConfig displays a profile-first authentication form for Bedrock configuration
|
||||
func PromptForBedrockConfig(ctx context.Context, manager *task.Manager) (*BedrockConfig, error) {
|
||||
config := &BedrockConfig{}
|
||||
|
||||
// First, ask if user wants to use AWS profile authentication
|
||||
var useProfile bool
|
||||
profileQuestion := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Do you want to use an AWS profile for authentication?").
|
||||
Description("AWS profiles are managed via 'aws configure'").
|
||||
Value(&useProfile).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := profileQuestion.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get authentication method: %w", err)
|
||||
}
|
||||
|
||||
// If user declines profile authentication, show message and return error
|
||||
if !useProfile {
|
||||
fmt.Println("\nAWS profile authentication is currently the only supported method in the CLI.")
|
||||
fmt.Println("Please configure an AWS profile using 'aws configure' and try again.")
|
||||
return nil, fmt.Errorf("user declined profile authentication")
|
||||
}
|
||||
|
||||
// User wants profile auth - collect profile configuration
|
||||
config.UseProfile = true
|
||||
config.Authentication = "profile"
|
||||
|
||||
// Collect profile name, region, and optional settings
|
||||
configForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("AWS Profile Name (optional, press Enter for default profile)").
|
||||
Value(&config.Profile).
|
||||
Description("Leave empty to use default AWS profile"),
|
||||
|
||||
huh.NewInput().
|
||||
Title("AWS Region (required, e.g., us-east-1)").
|
||||
Value(&config.Region).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("AWS Region is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
huh.NewInput().
|
||||
Title("Custom VPC Endpoint URL (optional)").
|
||||
Value(&config.Endpoint).
|
||||
Description("Press Enter to skip"),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Prompt Cache? ").
|
||||
Value(&config.UsePromptCache).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Enable Cross-Region Inference? ").
|
||||
Value(&config.UseCrossRegionInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
|
||||
huh.NewConfirm().
|
||||
Title("Use Global Inference Endpoint? ").
|
||||
Value(&config.UseGlobalInference).
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Inline(true),
|
||||
),
|
||||
)
|
||||
|
||||
if err := configForm.Run(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields
|
||||
config.Profile = strings.TrimSpace(config.Profile)
|
||||
config.Region = strings.TrimSpace(config.Region)
|
||||
config.Endpoint = strings.TrimSpace(config.Endpoint)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// ApplyBedrockConfig applies Bedrock configuration using partial updates (profile-only)
|
||||
func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *BedrockConfig, modelID string, modelInfo interface{}) error {
|
||||
// Build the API configuration with all Bedrock fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set model ID fields
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
apiConfig.ActModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
|
||||
// Set profile authentication fields (always required)
|
||||
optionalFields := &BedrockOptionalFields{}
|
||||
optionalFields.Authentication = proto.String("profile")
|
||||
optionalFields.UseProfile = proto.Bool(true)
|
||||
optionalFields.Region = proto.String(config.Region)
|
||||
|
||||
// Set profile name (can be empty for default profile)
|
||||
if config.Profile != "" {
|
||||
optionalFields.Profile = proto.String(config.Profile)
|
||||
}
|
||||
|
||||
// Set optional fields if provided
|
||||
if config.Endpoint != "" {
|
||||
optionalFields.Endpoint = proto.String(config.Endpoint)
|
||||
}
|
||||
if config.UseCrossRegionInference {
|
||||
optionalFields.UseCrossRegionInference = proto.Bool(true)
|
||||
}
|
||||
if config.UseGlobalInference {
|
||||
optionalFields.UseGlobalInference = proto.Bool(true)
|
||||
}
|
||||
if config.UsePromptCache {
|
||||
optionalFields.UsePromptCache = proto.Bool(true)
|
||||
}
|
||||
|
||||
// Apply all fields to the config
|
||||
setBedrockOptionalFields(apiConfig, optionalFields)
|
||||
|
||||
// Build field mask including all fields we're setting (excluding access keys)
|
||||
fieldPaths := []string{
|
||||
"planModeApiModelId",
|
||||
"actModeApiModelId",
|
||||
"planModeAwsBedrockCustomModelBaseId",
|
||||
"actModeAwsBedrockCustomModelBaseId",
|
||||
}
|
||||
|
||||
// Add profile authentication field paths
|
||||
optionalPaths := buildBedrockOptionalFieldMask(optionalFields)
|
||||
fieldPaths = append(fieldPaths, optionalPaths...)
|
||||
|
||||
// Create field mask
|
||||
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
|
||||
|
||||
// Apply the partial update
|
||||
request := &cline.UpdateApiConfigurationPartialRequest{
|
||||
ApiConfiguration: apiConfig,
|
||||
UpdateMask: fieldMask,
|
||||
}
|
||||
|
||||
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
|
||||
return fmt.Errorf("failed to apply Bedrock configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/config"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/task"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var configManager *config.Manager
|
||||
|
||||
func ensureConfigManager(ctx context.Context, address string) error {
|
||||
if configManager == nil || (address != "" && configManager.GetCurrentInstance() != address) {
|
||||
var err error
|
||||
var instanceAddress string
|
||||
|
||||
if address != "" {
|
||||
// Ensure instance exists at the specified address
|
||||
if err := ensureInstanceAtAddress(ctx, address); err != nil {
|
||||
return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, address)
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
configManager, err = config.NewManager(ctx, "")
|
||||
if err == nil {
|
||||
instanceAddress = configManager.GetCurrentInstance()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config manager: %w", err)
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Aliases: []string{"c"},
|
||||
Short: "Manage Cline configuration",
|
||||
Long: `Set and manage global Cline configuration variables.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newConfigListCommand())
|
||||
cmd.AddCommand(newConfigGetCommand())
|
||||
cmd.AddCommand(setCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigGetCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Get a specific configuration value",
|
||||
Long: `Get the value of a specific configuration setting. Supports nested keys using dot notation (e.g., auto-approval-settings.actions.read-files).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
key := args[0]
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the setting
|
||||
return configManager.GetSetting(ctx, key)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigListCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"l"},
|
||||
Short: "List all configuration settings",
|
||||
Long: `List all configuration settings from the Cline instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// List settings
|
||||
return configManager.ListSettings(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func setCommand() *cobra.Command {
|
||||
var address string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <key=value> [key=value...]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Set configuration variables",
|
||||
Long: `Set one or more global configuration variables using key=value format.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Parse using existing task parser
|
||||
settings, secrets, err := task.ParseTaskSettings(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse settings: %w", err)
|
||||
}
|
||||
|
||||
// Ensure config manager
|
||||
if err := ensureConfigManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update settings
|
||||
return configManager.UpdateSettings(ctx, settings, secrets)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *client.ClineClient
|
||||
clientAddress string
|
||||
}
|
||||
|
||||
func NewManager(ctx context.Context, address string) (*Manager, error) {
|
||||
var c *client.ClineClient
|
||||
var err error
|
||||
|
||||
if address != "" {
|
||||
c, err = global.GetClientForAddress(ctx, address)
|
||||
} else {
|
||||
c, err = global.GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Get the actual address being used
|
||||
clientAddress := address
|
||||
if address == "" && global.Clients != nil {
|
||||
clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
client: c,
|
||||
clientAddress: clientAddress,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentInstance returns the address of the current instance
|
||||
func (m *Manager) GetCurrentInstance() string {
|
||||
return m.clientAddress
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateSettings(ctx context.Context, settings *cline.Settings, secrets *cline.Secrets) error {
|
||||
request := &cline.UpdateSettingsRequestCli{
|
||||
Metadata: &cline.Metadata{},
|
||||
Settings: settings,
|
||||
Secrets: secrets,
|
||||
}
|
||||
|
||||
// Call the updateSettingsCli RPC
|
||||
_, err := m.client.State.UpdateSettingsCli(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update settings: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Settings updated successfully")
|
||||
fmt.Printf("Instance: %s\n", m.clientAddress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetState(ctx context.Context) (map[string]interface{}, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
var stateData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state: %w", err)
|
||||
}
|
||||
|
||||
return stateData, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListSettings(ctx context.Context) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subset of fields we will print the values for
|
||||
settingsFields := []string{
|
||||
"apiConfiguration",
|
||||
"telemetrySetting",
|
||||
"planActSeparateModelsSetting",
|
||||
"enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled",
|
||||
"shellIntegrationTimeout",
|
||||
"terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed",
|
||||
"mcpDisplayMode",
|
||||
"terminalOutputLineLimit",
|
||||
"mode",
|
||||
"preferredLanguage",
|
||||
"openaiReasoningEffort",
|
||||
"strictPlanModeEnabled",
|
||||
"focusChainSettings",
|
||||
"useAutoCondense",
|
||||
"customPrompt",
|
||||
"browserSettings",
|
||||
"defaultTerminalProfile",
|
||||
"yoloModeToggled",
|
||||
"dictationSettings",
|
||||
"autoCondenseThreshold",
|
||||
"autoApprovalSettings",
|
||||
}
|
||||
|
||||
// Render each field using the renderer
|
||||
for _, field := range settingsFields {
|
||||
if value, ok := stateData[field]; ok {
|
||||
if err := RenderField(field, value, true); err != nil {
|
||||
fmt.Printf("Error rendering %s: %v\n", field, err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetSetting(ctx context.Context, key string) error {
|
||||
// Get state
|
||||
stateData, err := m.GetState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert kebab-case to camelCase path
|
||||
parts := kebabToCamelPath(key)
|
||||
rootField := parts[0]
|
||||
|
||||
// Get the value
|
||||
value, found := getNestedValue(stateData, parts)
|
||||
if !found {
|
||||
return fmt.Errorf("setting '%s' not found", key)
|
||||
}
|
||||
|
||||
// Render the value
|
||||
if len(parts) == 1 {
|
||||
// Top-level field: use RenderField for nice formatting
|
||||
return RenderField(rootField, value, false)
|
||||
} else {
|
||||
// Nested field: simple print
|
||||
fmt.Printf("%s: %s\n", key, formatValue(value, rootField, true))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// kebabToCamelPath converts a kebab-case path to camelCase
|
||||
// e.g., "auto-approval-settings.actions.read-files" -> "autoApprovalSettings.actions.readFiles"
|
||||
func kebabToCamelPath(path string) []string {
|
||||
parts := strings.Split(path, ".")
|
||||
for i, part := range parts {
|
||||
parts[i] = kebabToCamel(part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// kebabToCamel converts a single kebab-case string to camelCase
|
||||
// e.g., "auto-approval-settings" -> "autoApprovalSettings"
|
||||
func kebabToCamel(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) == 1 {
|
||||
return s
|
||||
}
|
||||
|
||||
// First part stays lowercase, rest are capitalized
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
if parts[i] != "" {
|
||||
result += strings.ToUpper(parts[i][:1]) + parts[i][1:]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// getNestedValue retrieves a value from a nested map using dot notation
|
||||
// e.g., "autoApprovalSettings.actions.readFiles"
|
||||
func getNestedValue(data map[string]interface{}, parts []string) (interface{}, bool) {
|
||||
current := interface{}(data)
|
||||
|
||||
for _, part := range parts {
|
||||
// Try to access as map
|
||||
if m, ok := current.(map[string]interface{}); ok {
|
||||
if val, exists := m[part]; exists {
|
||||
current = val
|
||||
continue
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return current, true
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sensitiveKeywords defines field name patterns that should be censored
|
||||
var sensitiveKeywords = []string{"key", "secret", "password", "cline-account-id"}
|
||||
|
||||
// camelToKebab converts camelCase to kebab-case
|
||||
// e.g., "autoApprovalSettings" -> "auto-approval-settings"
|
||||
func camelToKebab(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
var result []rune
|
||||
for i, r := range s {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||
result = append(result, '-')
|
||||
}
|
||||
result = append(result, r|32) // Convert to lowercase (works for A-Z)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// isSensitiveField checks if a field name contains sensitive keywords
|
||||
func isSensitiveField(fieldName string) bool {
|
||||
if fieldName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lowerName := strings.ToLower(fieldName)
|
||||
for _, keyword := range sensitiveKeywords {
|
||||
if strings.Contains(lowerName, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatValue formats a value for display, handling empty strings and censoring sensitive fields
|
||||
func formatValue(val interface{}, fieldName string, censor bool) string {
|
||||
// Handle empty strings specifically
|
||||
if str, ok := val.(string); ok && str == "" {
|
||||
return "''"
|
||||
}
|
||||
|
||||
if censor && isSensitiveField(fieldName) {
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if valStr != "" && valStr != "''" {
|
||||
return "********"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
|
||||
// RenderField renders a single config field with proper formatting
|
||||
func RenderField(key string, value interface{}, censor bool) error {
|
||||
switch key {
|
||||
// Nested objects - render with header + nested fields
|
||||
case "apiConfiguration":
|
||||
return renderApiConfiguration(value, censor)
|
||||
case "browserSettings":
|
||||
return renderBrowserSettings(value, censor)
|
||||
case "focusChainSettings":
|
||||
return renderFocusChainSettings(value, censor)
|
||||
case "dictationSettings":
|
||||
return renderDictationSettings(value, censor)
|
||||
case "autoApprovalSettings":
|
||||
return renderAutoApprovalSettings(value, censor)
|
||||
|
||||
// Simple values - just print key: value
|
||||
case "mode", "telemetrySetting", "preferredLanguage", "customPrompt",
|
||||
"defaultTerminalProfile", "mcpDisplayMode", "openaiReasoningEffort",
|
||||
"planActSeparateModelsSetting", "enableCheckpointsSetting",
|
||||
"mcpMarketplaceEnabled", "terminalReuseEnabled",
|
||||
"mcpResponsesCollapsed", "strictPlanModeEnabled",
|
||||
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold":
|
||||
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown config field: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
// renderApiConfiguration renders the API configuration object
|
||||
func renderApiConfiguration(value interface{}, censor bool) error {
|
||||
fmt.Println("api-configuration:")
|
||||
|
||||
configMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid api-configuration format")
|
||||
}
|
||||
|
||||
// Print each field directly
|
||||
for key, val := range configMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderBrowserSettings renders browser settings
|
||||
func renderBrowserSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("browser-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid browser-settings format")
|
||||
}
|
||||
|
||||
// Handle nested viewport if present
|
||||
if viewport, ok := settingsMap["viewport"].(map[string]interface{}); ok {
|
||||
fmt.Println(" viewport:")
|
||||
for key, val := range viewport {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
// Print other fields
|
||||
for key, val := range settingsMap {
|
||||
if key != "viewport" {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderFocusChainSettings renders focus chain settings
|
||||
func renderFocusChainSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("focus-chain-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid focus-chain-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDictationSettings renders dictation settings
|
||||
func renderDictationSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("dictation-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid dictation-settings format")
|
||||
}
|
||||
|
||||
for key, val := range settingsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAutoApprovalSettings renders auto approval settings
|
||||
func renderAutoApprovalSettings(value interface{}, censor bool) error {
|
||||
fmt.Println("auto-approval-settings:")
|
||||
|
||||
settingsMap, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid auto-approval-settings format")
|
||||
}
|
||||
|
||||
// Print top-level fields (skip version, handle actions specially)
|
||||
for key, val := range settingsMap {
|
||||
if key == "version" {
|
||||
continue // Skip version
|
||||
}
|
||||
|
||||
if key == "actions" {
|
||||
// Handle nested actions with double indentation
|
||||
fmt.Println(" actions:")
|
||||
if actionsMap, ok := val.(map[string]interface{}); ok {
|
||||
for actionKey, actionVal := range actionsMap {
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(actionKey), formatValue(actionVal, actionKey, censor))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Print other fields normally (enabled, maxRequests, enableNotifications, favorites)
|
||||
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func isTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
func ClearLine() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\r\033[K")
|
||||
}
|
||||
|
||||
// ClearToEnd clears from cursor to end of screen
|
||||
func ClearToEnd() {
|
||||
if !isTTY() {
|
||||
return
|
||||
}
|
||||
fmt.Print("\033[J")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type MarkdownRenderer struct {
|
||||
renderer *glamour.TermRenderer
|
||||
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
|
||||
}
|
||||
}`
|
||||
|
||||
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
|
||||
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.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MarkdownRenderer{
|
||||
renderer: r,
|
||||
width: 0, // Unlimited width
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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.WithWordWrap(width),
|
||||
glamour.WithPreservedNewLines(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MarkdownRenderer{
|
||||
renderer: r,
|
||||
width: width,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width.
|
||||
// Falls back to 120 if terminal width cannot be determined.
|
||||
func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) {
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width == 0 {
|
||||
width = 120 // Fallback width
|
||||
}
|
||||
return NewMarkdownRendererWithWidth(width)
|
||||
}
|
||||
|
||||
func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
|
||||
rendered, err := mr.renderer.Render(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package display
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
@@ -11,36 +10,55 @@ import (
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
typewriter *TypewriterPrinter
|
||||
typewriter *TypewriterPrinter
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
func NewRenderer() *Renderer {
|
||||
func NewRenderer(outputFormat string) *Renderer {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderMessage renders a message with timestamp and prefix
|
||||
func (r *Renderer) RenderMessage(timestamp, prefix, text string) error {
|
||||
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := r.sanitizeText(text)
|
||||
if cleanText == "" {
|
||||
clean := r.sanitizeText(text)
|
||||
if clean == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.typewriter.PrintMessageLine(timestamp, prefix, cleanText)
|
||||
if newline {
|
||||
fmt.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
fmt.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderCommand renders a command execution
|
||||
func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error {
|
||||
|
||||
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(timestamp, "EXEC", command)
|
||||
r.typewriter.PrintMessageLine("EXEC", command)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "CMD", command)
|
||||
r.typewriter.PrintMessageLine("CMD", command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -66,25 +84,28 @@ func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites
|
||||
return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost)
|
||||
}
|
||||
|
||||
// RenderAPI renders API request information
|
||||
func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error {
|
||||
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
|
||||
if apiInfo.Cost >= 0 {
|
||||
message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost))
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
|
||||
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
} else {
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", status)
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderRetry renders retry information
|
||||
func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error {
|
||||
func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
|
||||
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
|
||||
if delaySec > 0 {
|
||||
message += fmt.Sprintf(" in %d seconds", delaySec)
|
||||
}
|
||||
message += "..."
|
||||
r.typewriter.PrintMessageLine(timestamp, "API INFO", message)
|
||||
r.typewriter.PrintMessageLine("API INFO", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -124,9 +145,8 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
|
||||
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
if global.Config.Verbose {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
message := fmt.Sprintf(format, args...)
|
||||
r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message)
|
||||
r.typewriter.PrintMessageLine("[DEBUG]", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -174,3 +194,28 @@ func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
|
||||
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
|
||||
return r.typewriter
|
||||
}
|
||||
|
||||
func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
|
||||
return r.mdRenderer
|
||||
}
|
||||
|
||||
// RenderMarkdown renders markdown text to terminal format with ANSI codes
|
||||
// Falls back to plaintext if markdown rendering is unavailable or fails
|
||||
// Respects output format - skips rendering in plain mode
|
||||
func (r *Renderer) RenderMarkdown(markdown string) string {
|
||||
// Skip markdown rendering in plain mode
|
||||
if r.outputFormat == "plain" {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if r.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := r.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
type StreamingSegment struct {
|
||||
mu sync.Mutex
|
||||
sayType string
|
||||
prefix string
|
||||
buffer strings.Builder
|
||||
frozen bool
|
||||
mdRenderer *MarkdownRenderer
|
||||
toolRenderer *ToolRenderer
|
||||
shouldMarkdown bool
|
||||
outputFormat string
|
||||
msg *types.ClineMessage
|
||||
toolParser *ToolResultParser
|
||||
}
|
||||
|
||||
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
|
||||
ss := &StreamingSegment{
|
||||
sayType: sayType,
|
||||
prefix: prefix,
|
||||
mdRenderer: mdRenderer,
|
||||
toolRenderer: NewToolRenderer(mdRenderer, outputFormat),
|
||||
shouldMarkdown: shouldMarkdown,
|
||||
outputFormat: outputFormat,
|
||||
msg: msg,
|
||||
toolParser: NewToolResultParser(mdRenderer),
|
||||
}
|
||||
|
||||
// Render rich header immediately when creating segment (if in rich mode)
|
||||
if shouldMarkdown && outputFormat != "plain" {
|
||||
header := ss.generateRichHeader()
|
||||
rendered, _ := mdRenderer.Render(header)
|
||||
fmt.Println()
|
||||
fmt.Print(rendered)
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) AppendText(text string) {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace buffer with FULL text - msg.Text contains complete accumulated content
|
||||
ss.buffer.Reset()
|
||||
ss.buffer.WriteString(text)
|
||||
|
||||
// No rendering during streaming - we'll render once on Freeze()
|
||||
}
|
||||
|
||||
|
||||
func (ss *StreamingSegment) Freeze() {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
|
||||
if ss.frozen {
|
||||
return
|
||||
}
|
||||
|
||||
ss.frozen = true
|
||||
currentBuffer := ss.buffer.String()
|
||||
|
||||
// Render and print the final markdown
|
||||
ss.renderFinal(currentBuffer)
|
||||
}
|
||||
|
||||
func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
var bodyContent string
|
||||
|
||||
// Use ToolRenderer for all body rendering to centralize logic
|
||||
if ss.sayType == "ask" {
|
||||
// Handle ASK messages
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
// Tool approval: use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
// For approval requests in streaming, use the preview method
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentPreview(&tool)
|
||||
}
|
||||
} else if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
// Followup question: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GenerateAskFollowupBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
|
||||
// Plan mode respond: use ToolRenderer
|
||||
bodyContent = ss.toolRenderer.GeneratePlanModeRespondBody(currentBuffer)
|
||||
} else if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
// Command approval: no body needed - header shows command, output shown separately later
|
||||
bodyContent = ""
|
||||
} else {
|
||||
// For other ask types, render as-is
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeTool) {
|
||||
// Tool execution (SAY): use ToolRenderer for body
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
|
||||
bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool)
|
||||
}
|
||||
} else if ss.sayType == string(types.SayTypeCommand) {
|
||||
// Command output
|
||||
bodyContent = "```shell\n" + currentBuffer + "\n```"
|
||||
// Render markdown
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
rendered, err := ss.mdRenderer.Render(bodyContent)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other types (reasoning, text, etc.), render markdown as-is
|
||||
if ss.shouldMarkdown && ss.outputFormat != "plain" {
|
||||
rendered, err := ss.mdRenderer.Render(currentBuffer)
|
||||
if err == nil {
|
||||
bodyContent = rendered
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
} else {
|
||||
bodyContent = currentBuffer
|
||||
}
|
||||
}
|
||||
|
||||
// Print the body content
|
||||
if bodyContent != "" {
|
||||
if !strings.HasSuffix(bodyContent, "\n") {
|
||||
fmt.Print(bodyContent)
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Print(bodyContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// generateRichHeader generates a contextual header for the segment
|
||||
func (ss *StreamingSegment) generateRichHeader() string {
|
||||
switch ss.sayType {
|
||||
case string(types.SayTypeReasoning):
|
||||
return "### Cline is thinking\n"
|
||||
|
||||
case string(types.SayTypeText):
|
||||
return "### Cline responds\n"
|
||||
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "### Task completed\n"
|
||||
|
||||
case string(types.SayTypeTool):
|
||||
return ss.generateToolHeader()
|
||||
|
||||
case "ask":
|
||||
// Check the specific ask type
|
||||
if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
|
||||
return ss.toolRenderer.GeneratePlanModeRespondHeader()
|
||||
}
|
||||
|
||||
// For tool approvals, show proper tool header
|
||||
if ss.msg.Ask == string(types.AskTypeTool) {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err == nil {
|
||||
// Use ToolRenderer for approval header with "wants to" verbs
|
||||
return ss.toolRenderer.RenderToolApprovalHeader(&tool)
|
||||
}
|
||||
}
|
||||
|
||||
// For command approvals, show command header
|
||||
if ss.msg.Ask == string(types.AskTypeCommand) {
|
||||
command := strings.TrimSpace(ss.msg.Text)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
}
|
||||
return fmt.Sprintf("### Cline wants to run `%s`\n", command)
|
||||
}
|
||||
|
||||
// For followup questions, show question header
|
||||
if ss.msg.Ask == string(types.AskTypeFollowup) {
|
||||
return ss.toolRenderer.GenerateAskFollowupHeader()
|
||||
}
|
||||
|
||||
// For other ask types, show generic message
|
||||
return fmt.Sprintf("### Cline is asking (%s)\n", ss.msg.Ask)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("### %s\n", ss.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
// generateToolHeader generates a contextual header for tool operations
|
||||
func (ss *StreamingSegment) generateToolHeader() string {
|
||||
// Parse tool JSON from message text
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil {
|
||||
return "### Tool operation\n"
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer for header
|
||||
return ss.toolRenderer.RenderToolExecutionHeader(&tool)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,18 +10,26 @@ import (
|
||||
|
||||
// StreamingDisplay manages streaming message display with deduplication
|
||||
type StreamingDisplay struct {
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
mu sync.RWMutex
|
||||
state *types.ConversationState
|
||||
renderer *Renderer
|
||||
dedupe *MessageDeduplicator
|
||||
activeSegment *StreamingSegment
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewStreamingDisplay creates a new streaming display manager
|
||||
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
|
||||
mdRenderer, err := NewMarkdownRenderer()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to initialize markdown renderer: %v", err))
|
||||
}
|
||||
|
||||
return &StreamingDisplay{
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
dedupe: NewMessageDeduplicator(),
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,418 +38,96 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
messageKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
// Check for deduplication
|
||||
if s.dedupe.IsDuplicate(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current streaming state
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
// Segment-based header-only streaming
|
||||
// Partial stream only shows headers immediately, state stream will handle content bodies
|
||||
sayType := msg.Say
|
||||
if msg.Type == types.MessageTypeAsk {
|
||||
sayType = "ask"
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case types.MessageTypeAsk:
|
||||
return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg)
|
||||
case types.MessageTypeSay:
|
||||
return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg)
|
||||
// Detect segment boundary
|
||||
if s.activeSegment != nil && s.activeSegment.sayType != sayType {
|
||||
// Just cleanup, don't freeze (no body to print)
|
||||
s.activeSegment = nil
|
||||
}
|
||||
|
||||
// On first partial message for a new segment type, create segment (prints header)
|
||||
if s.activeSegment == nil && msg.Partial {
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
// NewStreamingSegment prints the header immediately
|
||||
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
// Header printed, done - don't append text or freeze
|
||||
return nil
|
||||
}
|
||||
|
||||
// For subsequent partial messages, do nothing (header already shown)
|
||||
if msg.Partial {
|
||||
return nil
|
||||
}
|
||||
|
||||
// When message is complete (partial=false), render the content body
|
||||
if s.activeSegment != nil {
|
||||
// Had an active segment from partial messages - freeze to render body
|
||||
s.activeSegment.AppendText(msg.Text)
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
} else if !msg.Partial {
|
||||
// Message arrived complete without partial phase - create segment and render immediately
|
||||
shouldMd := s.shouldRenderMarkdown(sayType)
|
||||
prefix := s.getPrefix(sayType)
|
||||
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
|
||||
segment.AppendText(msg.Text)
|
||||
segment.Freeze()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
|
||||
return true
|
||||
default:
|
||||
return s.renderer.RenderMessage(timestamp, "🤖", msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingAsk handles streaming ASK messages
|
||||
func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same ASK message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// This is an update to the same ASK message - stream the changes
|
||||
if cleanText != streamingMsg.LastText {
|
||||
s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new ASK message
|
||||
s.finishCurrentStream()
|
||||
s.streamAskMessage(cleanText, timestamp, true)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingSay handles streaming SAY messages
|
||||
func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeText), string(types.SayTypeCompletionResult):
|
||||
return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommand):
|
||||
return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeTool):
|
||||
return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg)
|
||||
default:
|
||||
// For non-streaming message types, use regular display
|
||||
return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStreamingText handles streaming text messages
|
||||
func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil // Duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
// Show only the new characters with typewriter effect
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Text changed in a non-incremental way - replace the line
|
||||
s.renderer.ClearLine()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// This is a new message
|
||||
s.finishCurrentStream()
|
||||
prefix := s.getMessagePrefix(msg.Say)
|
||||
s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix)
|
||||
|
||||
// Add typewriter animation for new messages
|
||||
s.typewriterPrint(cleanText)
|
||||
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommand handles command execution messages
|
||||
func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show command being executed with typewriter effect
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingCommandOutput handles streaming command output
|
||||
func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've already displayed this exact message
|
||||
if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an update to the same message
|
||||
if streamingMsg.CurrentKey == messageKey {
|
||||
// Show incremental changes with typewriter effect
|
||||
if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) {
|
||||
newChars := cleanText[len(streamingMsg.LastText):]
|
||||
s.typewriterPrint(newChars)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
} else {
|
||||
// Non-incremental change - replace the line
|
||||
s.renderer.ClearLine()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
} else {
|
||||
// New command output message
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp)
|
||||
s.typewriterPrint(cleanText)
|
||||
s.state.SetStreamingMessage(messageKey, cleanText)
|
||||
}
|
||||
|
||||
// If message is complete, add newline
|
||||
if !msg.Partial {
|
||||
fmt.Println()
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show a more concise shell integration warning
|
||||
s.finishCurrentStream()
|
||||
s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp)
|
||||
s.typewriterPrint("Command executed (output not streamed due to shell integration)")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStreamingTool handles streaming tool messages with deduplication
|
||||
func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error {
|
||||
cleanText := s.renderer.sanitizeText(msg.Text)
|
||||
if cleanText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formattedTool := s.formatToolMessage(cleanText)
|
||||
|
||||
// Check if this is the exact same tool message we just displayed
|
||||
if streamingMsg.LastToolMessage == formattedTool {
|
||||
return nil // Exact duplicate - ignore it
|
||||
}
|
||||
|
||||
// Check if this is a very similar tool message
|
||||
if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) {
|
||||
return nil // Similar duplicate - ignore it
|
||||
}
|
||||
|
||||
// This is a genuinely new/different tool message
|
||||
s.finishCurrentStream()
|
||||
fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool)
|
||||
|
||||
// Store the formatted tool message for deduplication
|
||||
s.state.StreamingMessage.LastToolMessage = formattedTool
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamAskMessage streams an ASK message in a natural format
|
||||
func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) {
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := s.parseJSON(text, &askData); err != nil {
|
||||
// Display as text but sanitized
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response)
|
||||
|
||||
// Display options if available
|
||||
if len(askData.Options) > 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
for i, option := range askData.Options {
|
||||
fmt.Printf("\n%d. %s", i+1, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamAskMessageUpdate handles updates to an existing ASK message
|
||||
func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) {
|
||||
var oldAskData, newAskData types.AskData
|
||||
|
||||
oldErr := s.parseJSON(oldText, &oldAskData)
|
||||
newErr := s.parseJSON(newText, &newAskData)
|
||||
|
||||
if oldErr != nil || newErr != nil {
|
||||
// Handle plain text incremental updates
|
||||
if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) {
|
||||
newChars := newText[len(oldText):]
|
||||
fmt.Print(newChars)
|
||||
} else {
|
||||
// Non-incremental change - clear line and reprint everything
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured updates
|
||||
if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) {
|
||||
newChars := newAskData.Response[len(oldAskData.Response):]
|
||||
fmt.Print(newChars)
|
||||
} else if oldAskData.Response != newAskData.Response {
|
||||
s.renderer.ClearLine()
|
||||
fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
if len(newAskData.Options) > len(oldAskData.Options) {
|
||||
if len(oldAskData.Options) == 0 {
|
||||
fmt.Print("\n\nOptions:")
|
||||
}
|
||||
|
||||
for i := len(oldAskData.Options); i < len(newAskData.Options); i++ {
|
||||
fmt.Printf("\n%d. %s", i+1, newAskData.Options[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// typewriterPrint displays text with a typewriter animation effect
|
||||
func (s *StreamingDisplay) typewriterPrint(text string) {
|
||||
// Use the renderer's typewriter for consistent animation
|
||||
s.renderer.typewriter.Print(text)
|
||||
}
|
||||
|
||||
// finishCurrentStream completes any ongoing streaming message
|
||||
func (s *StreamingDisplay) finishCurrentStream() {
|
||||
streamingMsg := s.state.GetStreamingMessage()
|
||||
if streamingMsg.CurrentKey != "" {
|
||||
//fmt.Println() // Add newline to finish the current streaming message
|
||||
s.state.SetStreamingMessage("", "")
|
||||
}
|
||||
}
|
||||
|
||||
// getMessagePrefix returns the appropriate prefix for a message type
|
||||
func (s *StreamingDisplay) getMessagePrefix(say string) string {
|
||||
switch say {
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "✅ RESULT"
|
||||
case string(types.SayTypeText):
|
||||
return "🤖"
|
||||
default:
|
||||
return "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
// formatToolMessage formats tool call messages for better readability
|
||||
func (s *StreamingDisplay) formatToolMessage(text string) string {
|
||||
var toolCall map[string]interface{}
|
||||
if err := s.parseJSON(text, &toolCall); err == nil {
|
||||
if tool, ok := toolCall["tool"].(string); ok {
|
||||
parts := []string{tool}
|
||||
|
||||
if path, ok := toolCall["path"].(string); ok && path != "" {
|
||||
parts = append(parts, fmt.Sprintf("path=%s", path))
|
||||
}
|
||||
|
||||
if content, ok := toolCall["content"].(string); ok && content != "" {
|
||||
if len(content) > 50 {
|
||||
parts = append(parts, fmt.Sprintf("content=%s...", content[:50]))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("content=%s", content))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON or doesn't have expected structure, return truncated
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates
|
||||
func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool {
|
||||
parts1 := strings.Fields(msg1)
|
||||
parts2 := strings.Fields(msg2)
|
||||
|
||||
if len(parts1) == 0 || len(parts2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the first word (tool name) is the same, check for similarity
|
||||
if parts1[0] == parts2[0] {
|
||||
// For file operations, check if the path is the same
|
||||
if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") {
|
||||
path1 := s.extractPathFromToolMessage(msg1)
|
||||
path2 := s.extractPathFromToolMessage(msg2)
|
||||
|
||||
if path1 != "" && path1 == path2 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// For very similar content (>80% similarity), consider them duplicates
|
||||
similarity := s.calculateStringSimilarity(msg1, msg2)
|
||||
return similarity > 0.8
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPathFromToolMessage extracts the path parameter from a tool message
|
||||
func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string {
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "path=") {
|
||||
return strings.TrimPrefix(part, "path=")
|
||||
}
|
||||
func (s *StreamingDisplay) getPrefix(sayType string) string {
|
||||
switch sayType {
|
||||
case string(types.SayTypeReasoning):
|
||||
return "THINKING"
|
||||
case string(types.SayTypeText):
|
||||
return "CLINE"
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return "RESULT"
|
||||
case "ask":
|
||||
return "ASK"
|
||||
case string(types.SayTypeCommand):
|
||||
return "TERMINAL"
|
||||
default:
|
||||
return strings.ToUpper(sayType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates a simple similarity ratio between two strings
|
||||
func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
func (s *StreamingDisplay) FreezeActiveSegment() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.activeSegment != nil {
|
||||
s.activeSegment.Freeze()
|
||||
s.activeSegment = nil
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
shorter, longer := s1, s2
|
||||
if len(s1) > len(s2) {
|
||||
shorter, longer = s2, s1
|
||||
}
|
||||
|
||||
matches := 0
|
||||
for i, r := range shorter {
|
||||
if i < len(longer) && rune(longer[i]) == r {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
|
||||
return float64(matches) / float64(len(longer))
|
||||
}
|
||||
|
||||
// parseJSON is a helper function to parse JSON with error handling
|
||||
func (s *StreamingDisplay) parseJSON(text string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(text), v)
|
||||
}
|
||||
|
||||
// Cleanup cleans up streaming display resources
|
||||
func (s *StreamingDisplay) Cleanup() {
|
||||
s.FreezeActiveSegment()
|
||||
if s.dedupe != nil {
|
||||
s.dedupe.Stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolRenderer provides unified rendering for tool and command messages
|
||||
type ToolRenderer struct {
|
||||
mdRenderer *MarkdownRenderer
|
||||
outputFormat string
|
||||
}
|
||||
|
||||
// NewToolRenderer creates a new tool renderer
|
||||
func NewToolRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *ToolRenderer {
|
||||
return &ToolRenderer{
|
||||
mdRenderer: mdRenderer,
|
||||
outputFormat: outputFormat,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderToolApprovalRequest renders a tool approval request ("Cline wants to...")
|
||||
func (tr *ToolRenderer) RenderToolApprovalRequest(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content preview for relevant tools
|
||||
contentPreview := tr.GenerateToolContentPreview(tool)
|
||||
if contentPreview != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentPreview)
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecution renders a completed tool execution ("Cline is ...ing")
|
||||
func (tr *ToolRenderer) RenderToolExecution(tool *types.ToolMessage) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Generate header
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Add content body for relevant tools
|
||||
contentBody := tr.GenerateToolContentBody(tool)
|
||||
if contentBody != "" {
|
||||
output.WriteString("\n")
|
||||
output.WriteString(contentBody)
|
||||
output.WriteString("\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderToolExecutionHeader renders just the header for streaming (no body)
|
||||
func (tr *ToolRenderer) RenderToolExecutionHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "is")
|
||||
return header
|
||||
}
|
||||
|
||||
// RenderToolApprovalHeader renders just the header for approval requests (no body)
|
||||
func (tr *ToolRenderer) RenderToolApprovalHeader(tool *types.ToolMessage) string {
|
||||
header := tr.generateToolHeader(tool, "wants to")
|
||||
return header
|
||||
}
|
||||
|
||||
// generateToolHeader generates the markdown header for a tool message
|
||||
func (tr *ToolRenderer) generateToolHeader(tool *types.ToolMessage, verbTense string) string {
|
||||
var verb string
|
||||
var action string
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to edit"
|
||||
} else {
|
||||
action = "is editing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to write"
|
||||
} else {
|
||||
action = "is writing"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeReadFile):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to read"
|
||||
} else {
|
||||
action = "is reading"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list files in"
|
||||
} else {
|
||||
action = "is listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to recursively list files in"
|
||||
} else {
|
||||
action = "is recursively listing files in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
if tool.Regex != "" && tool.Path != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s` in `%s`", action, tool.Regex, tool.Path)
|
||||
} else if tool.Regex != "" {
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to search for"
|
||||
} else {
|
||||
action = "is searching for"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Regex)
|
||||
} else {
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to search files"
|
||||
} else {
|
||||
return "### Cline is searching files"
|
||||
}
|
||||
}
|
||||
|
||||
case string(types.ToolTypeWebFetch):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to fetch"
|
||||
} else {
|
||||
action = "is fetching"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
if verbTense == "wants to" {
|
||||
action = "wants to list code definitions in"
|
||||
} else {
|
||||
action = "is listing code definitions in"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s `%s`", action, tool.Path)
|
||||
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
if verbTense == "wants to" {
|
||||
return "### Cline wants to condense the conversation"
|
||||
} else {
|
||||
return "### Cline condensed the conversation"
|
||||
}
|
||||
|
||||
default:
|
||||
if verbTense == "wants to" {
|
||||
verb = "wants to use"
|
||||
} else {
|
||||
verb = "is using"
|
||||
}
|
||||
return fmt.Sprintf("### Cline %s tool: %s", verb, tool.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentPreview generates content preview for approval requests
|
||||
func (tr *ToolRenderer) GenerateToolContentPreview(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show diff for edits
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show content preview for new files (truncated)
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "..."
|
||||
}
|
||||
previewMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(previewMd)
|
||||
|
||||
case string(types.ToolTypeReadFile), string(types.ToolTypeWebFetch):
|
||||
// No preview for read/fetch operations
|
||||
return ""
|
||||
|
||||
default:
|
||||
// For other tools, show truncated content if available
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
if preview != "" {
|
||||
return fmt.Sprintf("Preview: %s", preview)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToolContentBody generates full content for completed executions
|
||||
func (tr *ToolRenderer) GenerateToolContentBody(tool *types.ToolMessage) string {
|
||||
if tool.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use enhanced tool result parser for supported tools
|
||||
toolParser := NewToolResultParser(tr.mdRenderer)
|
||||
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeReadFile):
|
||||
// readFile: show header only, no body
|
||||
return ""
|
||||
|
||||
case string(types.ToolTypeListFilesTopLevel),
|
||||
string(types.ToolTypeListFilesRecursive),
|
||||
string(types.ToolTypeListCodeDefinitionNames),
|
||||
string(types.ToolTypeSearchFiles),
|
||||
string(types.ToolTypeWebFetch):
|
||||
// Use parser for structured output
|
||||
preview := toolParser.ParseToolResult(tool)
|
||||
return tr.renderMarkdown(preview)
|
||||
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
// Show the diff
|
||||
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
|
||||
return tr.renderMarkdown(diffMarkdown)
|
||||
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
// Show file content preview
|
||||
preview := strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
contentMd := fmt.Sprintf("```\n%s\n```", preview)
|
||||
return tr.renderMarkdown(contentMd)
|
||||
|
||||
default:
|
||||
// For unknown tools, show content as-is
|
||||
if len(tool.Content) > 500 {
|
||||
return tool.Content[:500] + "..."
|
||||
}
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
|
||||
// RenderCommandApprovalRequest renders a command approval request
|
||||
func (tr *ToolRenderer) RenderCommandApprovalRequest(command string, autoApprovalConflict bool) string {
|
||||
var output strings.Builder
|
||||
|
||||
// Clean command
|
||||
command = strings.TrimSpace(command)
|
||||
if strings.HasSuffix(command, "REQ_APP") {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
command = strings.TrimSpace(command)
|
||||
autoApprovalConflict = true
|
||||
}
|
||||
|
||||
// Generate header
|
||||
header := fmt.Sprintf("### Cline wants to run `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
output.WriteString(rendered)
|
||||
output.WriteString("\n")
|
||||
|
||||
// Show command in code block
|
||||
cmdBlock := fmt.Sprintf("```shell\n%s\n```", command)
|
||||
cmdRendered := tr.renderMarkdown(cmdBlock)
|
||||
output.WriteString("\n")
|
||||
output.WriteString(cmdRendered)
|
||||
|
||||
// Add warning if needed
|
||||
if autoApprovalConflict {
|
||||
output.WriteString("\nWARNING: The model has determined this command requires explicit approval.\n")
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// RenderCommandExecution renders a command execution announcement
|
||||
func (tr *ToolRenderer) RenderCommandExecution(command string) string {
|
||||
command = strings.TrimSpace(command)
|
||||
header := fmt.Sprintf("### Cline is running `%s`", command)
|
||||
rendered := tr.renderMarkdown(header)
|
||||
return "\n" + rendered + "\n"
|
||||
}
|
||||
|
||||
// RenderCommandOutput renders command output
|
||||
func (tr *ToolRenderer) RenderCommandOutput(output string) string {
|
||||
var result strings.Builder
|
||||
|
||||
header := "### Terminal output"
|
||||
rendered := tr.renderMarkdown(header)
|
||||
result.WriteString("\n")
|
||||
result.WriteString(rendered)
|
||||
result.WriteString("\n\n")
|
||||
|
||||
// Show output in code block
|
||||
outputBlock := fmt.Sprintf("```\n%s\n```", strings.TrimSpace(output))
|
||||
outputRendered := tr.renderMarkdown(outputBlock)
|
||||
result.WriteString(outputRendered)
|
||||
result.WriteString("\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// RenderUserResponse renders user approval/rejection feedback
|
||||
func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string {
|
||||
var symbol, status string
|
||||
|
||||
if approved {
|
||||
symbol = "✓"
|
||||
status = "Approved"
|
||||
} else {
|
||||
symbol = "✗"
|
||||
status = "Rejected"
|
||||
}
|
||||
|
||||
if feedback != "" {
|
||||
return fmt.Sprintf("%s %s with feedback: %s\n", symbol, status, feedback)
|
||||
}
|
||||
return fmt.Sprintf("%s %s\n", symbol, status)
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown if not in plain mode
|
||||
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
|
||||
if tr.outputFormat == "plain" {
|
||||
return markdown
|
||||
}
|
||||
|
||||
if tr.mdRenderer == nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
rendered, err := tr.mdRenderer.Render(markdown)
|
||||
if err != nil {
|
||||
return markdown
|
||||
}
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// GenerateAskFollowupHeader generates the header for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupHeader() string {
|
||||
return "### Cline has a question\n"
|
||||
}
|
||||
|
||||
// GenerateAskFollowupBody generates the body content for followup questions
|
||||
func (tr *ToolRenderer) GenerateAskFollowupBody(messageText string) string {
|
||||
var question string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(messageText), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = messageText
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the question
|
||||
rendered := tr.renderMarkdown(question)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondHeader generates the header for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondHeader() string {
|
||||
return "### Cline has a plan\n"
|
||||
}
|
||||
|
||||
// GeneratePlanModeRespondBody generates the body content for plan mode responses
|
||||
func (tr *ToolRenderer) GeneratePlanModeRespondBody(messageText string) string {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(messageText), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = messageText
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build the body
|
||||
var body strings.Builder
|
||||
|
||||
// Render the response
|
||||
rendered := tr.renderMarkdown(response)
|
||||
body.WriteString(rendered)
|
||||
|
||||
// Add options if available
|
||||
if len(options) > 0 {
|
||||
body.WriteString("\n\nOptions:\n")
|
||||
for i, option := range options {
|
||||
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
|
||||
}
|
||||
}
|
||||
|
||||
return body.String()
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// ToolResultParser handles parsing and formatting tool results for display
|
||||
type ToolResultParser struct {
|
||||
maxPreviewLines int
|
||||
maxPreviewChars int
|
||||
mdRenderer *MarkdownRenderer
|
||||
}
|
||||
|
||||
// NewToolResultParser creates a new tool result parser
|
||||
func NewToolResultParser(mdRenderer *MarkdownRenderer) *ToolResultParser {
|
||||
return &ToolResultParser{
|
||||
maxPreviewLines: 15,
|
||||
maxPreviewChars: 500,
|
||||
mdRenderer: mdRenderer,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseReadFile formats readFile tool results with smart preview
|
||||
func (p *ToolResultParser) ParseReadFile(content, path string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// Get file extension for syntax highlighting
|
||||
ext := filepath.Ext(path)
|
||||
lang := p.detectLanguage(ext)
|
||||
|
||||
var preview strings.Builder
|
||||
|
||||
// Show header with line count
|
||||
preview.WriteString(fmt.Sprintf("*%d lines*\n\n", totalLines))
|
||||
|
||||
// Show preview of content
|
||||
previewLines := p.maxPreviewLines
|
||||
if totalLines < previewLines {
|
||||
previewLines = totalLines
|
||||
}
|
||||
|
||||
preview.WriteString(fmt.Sprintf("```%s\n", lang))
|
||||
for i := 0; i < previewLines; i++ {
|
||||
preview.WriteString(lines[i])
|
||||
preview.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString("...\n")
|
||||
}
|
||||
preview.WriteString("```\n")
|
||||
|
||||
if totalLines > previewLines {
|
||||
preview.WriteString(fmt.Sprintf("\n*[Content truncated - showing %d of %d lines]*", previewLines, totalLines))
|
||||
}
|
||||
|
||||
return preview.String()
|
||||
}
|
||||
|
||||
// ParseListFiles formats listFiles tool results with directory tree
|
||||
func (p *ToolResultParser) ParseListFiles(content, path string) string {
|
||||
if content == "" || content == "No files found." {
|
||||
return "*No files found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||
|
||||
// Check for truncation message
|
||||
var truncationMsg string
|
||||
lastLine := lines[len(lines)-1]
|
||||
if strings.Contains(lastLine, "File list truncated") {
|
||||
truncationMsg = lastLine
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
totalFiles := len(lines)
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%d %s*\n\n", totalFiles, p.pluralize(totalFiles, "file", "files")))
|
||||
|
||||
// Show up to 20 files in tree format
|
||||
maxShow := 20
|
||||
if totalFiles < maxShow {
|
||||
maxShow = totalFiles
|
||||
}
|
||||
|
||||
result.WriteString("```\n")
|
||||
for i := 0; i < maxShow; i++ {
|
||||
line := lines[i]
|
||||
// Add tree characters for better visualization
|
||||
if strings.HasPrefix(line, "🔒 ") {
|
||||
result.WriteString("├── 🔒 ")
|
||||
result.WriteString(strings.TrimPrefix(line, "🔒 "))
|
||||
} else {
|
||||
result.WriteString("├── ")
|
||||
result.WriteString(line)
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString("└── ...\n")
|
||||
}
|
||||
result.WriteString("```\n")
|
||||
|
||||
if totalFiles > maxShow {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d of %d files]*", maxShow, totalFiles))
|
||||
}
|
||||
|
||||
if truncationMsg != "" {
|
||||
result.WriteString(fmt.Sprintf("\n\n*%s*", truncationMsg))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseSearchFiles formats searchFiles tool results with context
|
||||
func (p *ToolResultParser) ParseSearchFiles(content string) string {
|
||||
if content == "" || content == "Found 0 results." {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 {
|
||||
return "*No results found*"
|
||||
}
|
||||
|
||||
// Extract result count from first line
|
||||
firstLine := lines[0]
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("*%s*\n\n", firstLine))
|
||||
|
||||
// Parse and group results by file
|
||||
var currentFile string
|
||||
var fileResults []string
|
||||
filesShown := 0
|
||||
maxFiles := 5
|
||||
matchesShown := 0
|
||||
maxMatches := 15
|
||||
|
||||
for i := 1; i < len(lines) && filesShown < maxFiles && matchesShown < maxMatches; i++ {
|
||||
line := lines[i]
|
||||
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a file path (doesn't start with whitespace or line number)
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && strings.Contains(line, ":") {
|
||||
// Save previous file results
|
||||
if currentFile != "" && len(fileResults) > 0 {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
currentFile = line
|
||||
fileResults = []string{}
|
||||
} else if currentFile != "" {
|
||||
// This is a match line
|
||||
fileResults = append(fileResults, strings.TrimSpace(line))
|
||||
matchesShown++
|
||||
}
|
||||
}
|
||||
|
||||
// Add last file's results
|
||||
if currentFile != "" && len(fileResults) > 0 && filesShown < maxFiles {
|
||||
result.WriteString(p.formatFileMatches(currentFile, fileResults))
|
||||
filesShown++
|
||||
}
|
||||
|
||||
// Add truncation notice
|
||||
totalMatches := strings.Count(content, "\n") - 1 // Rough estimate
|
||||
if matchesShown < totalMatches {
|
||||
result.WriteString(fmt.Sprintf("\n*[Showing %d results - see full output for all matches]*", matchesShown))
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// formatFileMatches formats matches for a single file
|
||||
func (p *ToolResultParser) formatFileMatches(file string, matches []string) string {
|
||||
var result strings.Builder
|
||||
|
||||
// Parse file path and extension for syntax highlighting
|
||||
ext := filepath.Ext(file)
|
||||
lang := p.detectLanguage(ext)
|
||||
|
||||
result.WriteString(fmt.Sprintf("**%s** (%d %s)\n", file, len(matches), p.pluralize(len(matches), "match", "matches")))
|
||||
result.WriteString(fmt.Sprintf("```%s\n", lang))
|
||||
|
||||
maxMatches := 5
|
||||
for i, match := range matches {
|
||||
if i >= maxMatches {
|
||||
result.WriteString("...\n")
|
||||
break
|
||||
}
|
||||
result.WriteString(match)
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
result.WriteString("```\n\n")
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ParseCodeDefinitions formats listCodeDefinitionNames tool results
|
||||
func (p *ToolResultParser) ParseCodeDefinitions(content string) string {
|
||||
if content == "" || content == "No source code definitions found." {
|
||||
return "*No code definitions found*"
|
||||
}
|
||||
|
||||
// Return the full content as-is
|
||||
return content
|
||||
}
|
||||
|
||||
// ParseWebFetch formats webFetch tool results with content preview
|
||||
func (p *ToolResultParser) ParseWebFetch(content, url string) string {
|
||||
if content == "" {
|
||||
return fmt.Sprintf("*Fetched content from %s (empty response)*", url)
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
// Try to extract title
|
||||
var title string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "##") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
result.WriteString(fmt.Sprintf("**Title:** %s\n\n", title))
|
||||
}
|
||||
|
||||
// Show preview of content
|
||||
result.WriteString("**Preview:**\n")
|
||||
|
||||
charCount := 0
|
||||
maxChars := 500
|
||||
previewLines := []string{}
|
||||
|
||||
for _, line := range lines {
|
||||
// Skip markdown headers
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if charCount+len(trimmed) > maxChars {
|
||||
break
|
||||
}
|
||||
|
||||
previewLines = append(previewLines, trimmed)
|
||||
charCount += len(trimmed)
|
||||
}
|
||||
|
||||
result.WriteString(strings.Join(previewLines, " "))
|
||||
result.WriteString("...\n\n")
|
||||
|
||||
// Extract sections
|
||||
sections := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "##") {
|
||||
section := strings.TrimSpace(strings.TrimPrefix(trimmed, "##"))
|
||||
sections = append(sections, section)
|
||||
if len(sections) >= 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(sections) > 0 {
|
||||
result.WriteString("**Sections Found:**\n")
|
||||
for _, section := range sections {
|
||||
result.WriteString(fmt.Sprintf("- %s\n", section))
|
||||
}
|
||||
result.WriteString("\n")
|
||||
}
|
||||
|
||||
// Word count estimate
|
||||
wordCount := len(strings.Fields(content))
|
||||
result.WriteString(fmt.Sprintf("*[Full content: ~%s]*", p.formatWordCount(wordCount)))
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// detectLanguage returns syntax highlighting language based on file extension
|
||||
func (p *ToolResultParser) detectLanguage(ext string) string {
|
||||
langMap := map[string]string{
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".js": "javascript",
|
||||
".jsx": "jsx",
|
||||
".go": "go",
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".cs": "csharp",
|
||||
".php": "php",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "bash",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".xml": "xml",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".md": "markdown",
|
||||
".sql": "sql",
|
||||
".rs": "rust",
|
||||
}
|
||||
|
||||
if lang, ok := langMap[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pluralize returns the correct plural form
|
||||
func (p *ToolResultParser) pluralize(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return singular
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
// formatWordCount formats word count with appropriate unit
|
||||
func (p *ToolResultParser) formatWordCount(count int) string {
|
||||
if count < 1000 {
|
||||
return fmt.Sprintf("%d words", count)
|
||||
}
|
||||
return fmt.Sprintf("%.1fk words", float64(count)/1000.0)
|
||||
}
|
||||
|
||||
// ParseToolResult is the main entry point for parsing tool results
|
||||
func (p *ToolResultParser) ParseToolResult(tool *types.ToolMessage) string {
|
||||
switch tool.Tool {
|
||||
case "readFile":
|
||||
return p.ParseReadFile(tool.Content, tool.Path)
|
||||
case "listFilesTopLevel", "listFilesRecursive":
|
||||
return p.ParseListFiles(tool.Content, tool.Path)
|
||||
case "searchFiles":
|
||||
return p.ParseSearchFiles(tool.Content)
|
||||
case "listCodeDefinitionNames":
|
||||
return p.ParseCodeDefinitions(tool.Content)
|
||||
case "webFetch":
|
||||
return p.ParseWebFetch(tool.Content, tool.Path)
|
||||
default:
|
||||
return tool.Content
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,8 @@ func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
|
||||
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
|
||||
}
|
||||
|
||||
// PrintMessageLine prints a complete message line with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) {
|
||||
// Print the timestamp and prefix with 10-char padding
|
||||
tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix)
|
||||
// Print the message text with typewriter effect
|
||||
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
|
||||
tp.PrintfInstant("%s: ", prefix)
|
||||
tp.Println(text)
|
||||
}
|
||||
|
||||
@@ -193,9 +190,8 @@ func TypewriterPrintfLn(format string, args ...interface{}) {
|
||||
globalTypewriter.PrintfLn(format, args...)
|
||||
}
|
||||
|
||||
// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance
|
||||
func TypewriterPrintMessageLine(timestamp, prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(timestamp, prefix, text)
|
||||
func TypewriterPrintMessageLine(prefix, text string) {
|
||||
globalTypewriter.PrintMessageLine(prefix, text)
|
||||
}
|
||||
|
||||
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
@@ -92,7 +95,7 @@ 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.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())
|
||||
@@ -164,7 +167,7 @@ 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.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())
|
||||
@@ -211,11 +214,24 @@ 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)
|
||||
|
||||
// Get the directory where the cline binary is located
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
clineHostPath := path.Join(binDir, "cline-host")
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command("./cli/bin/cline-host",
|
||||
cmd := exec.Command(clineHostPath,
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
@@ -224,9 +240,76 @@ func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// KillInstanceByAddress kills a Cline instance by its address
|
||||
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
|
||||
|
||||
// Get paths relative to the cline binary location
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
binDir := path.Dir(execPath)
|
||||
installDir := path.Dir(binDir)
|
||||
nodePath := path.Join(binDir, "node")
|
||||
clineCorePath := path.Join(installDir, "cline-core.js")
|
||||
|
||||
// Create 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)
|
||||
@@ -235,28 +318,34 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
// Start the cline-core process with --config flag instead of CLINE_DIR env var
|
||||
args := []string{"cline-core.js",
|
||||
// Start the cline-core process with --config flag
|
||||
args := []string{clineCorePath,
|
||||
"--port", fmt.Sprintf("%d", corePort),
|
||||
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
|
||||
"--config", Config.ConfigPath}
|
||||
|
||||
fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args)
|
||||
fmt.Printf("DEBUG: Working directory: ./dist-standalone\n")
|
||||
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)
|
||||
|
||||
cmd := exec.Command("node", args...)
|
||||
cmd := exec.Command(nodePath, args...)
|
||||
|
||||
// Set working directory to dist-standalone (relative to project root)
|
||||
cmd.Dir = "./dist-standalone"
|
||||
// Set working directory to installation root
|
||||
cmd.Dir = installDir
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set environment variables (removed CLINE_DIR)
|
||||
// Put the child process in a new process group so Ctrl+C doesn't kill it
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
|
||||
// Set environment variables with NODE_PATH for node_modules
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
fmt.Sprintf("NODE_PATH=%s", path.Join(installDir, "node_modules")),
|
||||
"GRPC_TRACE=all",
|
||||
"GRPC_VERBOSITY=DEBUG",
|
||||
"NODE_ENV=development",
|
||||
|
||||
@@ -65,3 +65,27 @@ func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance exists
|
||||
func EnsureDefaultInstance(ctx context.Context) error {
|
||||
if Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
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
|
||||
}
|
||||
|
||||
@@ -110,6 +110,43 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
|
||||
// Check if the default instance actually exists in the database
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(defaultAddr)
|
||||
if err != nil {
|
||||
// Database is unavailable - Return error instead of attempting cleanup
|
||||
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Instance doesn't exist in database but config file references it
|
||||
// This is a stale config - remove it and try to find another instance
|
||||
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
|
||||
if removeErr := os.Remove(settingsPath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr)
|
||||
} else {
|
||||
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
|
||||
}
|
||||
|
||||
// Try to find and set a new default instance
|
||||
instances := r.ListInstances()
|
||||
if len(instances) > 0 {
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
return nil, fmt.Errorf("failed to set new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Retry with the new default
|
||||
newDefaultAddr := r.GetDefaultInstance()
|
||||
if newDefaultAddr != "" {
|
||||
fmt.Printf("Set new default instance: %s\n", newDefaultAddr)
|
||||
return r.GetClient(ctx, newDefaultAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
}
|
||||
|
||||
return r.GetClient(ctx, defaultAddr)
|
||||
}
|
||||
|
||||
@@ -223,15 +260,15 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co
|
||||
instances := r.ListInstances()
|
||||
|
||||
// 3. Ensure default is set if instances exist
|
||||
if err := r.ensureDefaultInstance(instances); err != nil {
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) ensureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
|
||||
@@ -25,262 +25,187 @@ func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
|
||||
return msg.IsAsk()
|
||||
}
|
||||
|
||||
// Handle processes ASK messages
|
||||
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
// Always display approval messages so user can see what they're approving
|
||||
// The input handler will show the approval prompt form after the content is displayed
|
||||
|
||||
switch msg.Ask {
|
||||
case string(types.AskTypeFollowup):
|
||||
return h.handleFollowup(msg, dc, timestamp)
|
||||
return h.handleFollowup(msg, dc)
|
||||
case string(types.AskTypePlanModeRespond):
|
||||
return h.handlePlanModeRespond(msg, dc, timestamp)
|
||||
return h.handlePlanModeRespond(msg, dc)
|
||||
case string(types.AskTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.AskTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.AskTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.AskTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.AskTypeAPIReqFailed):
|
||||
return h.handleAPIReqFailed(msg, dc, timestamp)
|
||||
return h.handleAPIReqFailed(msg, dc)
|
||||
case string(types.AskTypeResumeTask):
|
||||
return h.handleResumeTask(msg, dc, timestamp)
|
||||
return h.handleResumeTask(msg, dc)
|
||||
case string(types.AskTypeResumeCompletedTask):
|
||||
return h.handleResumeCompletedTask(msg, dc, timestamp)
|
||||
return h.handleResumeCompletedTask(msg, dc)
|
||||
case string(types.AskTypeMistakeLimitReached):
|
||||
return h.handleMistakeLimitReached(msg, dc, timestamp)
|
||||
return h.handleMistakeLimitReached(msg, dc)
|
||||
case string(types.AskTypeAutoApprovalMaxReached):
|
||||
return h.handleAutoApprovalMaxReached(msg, dc, timestamp)
|
||||
return h.handleAutoApprovalMaxReached(msg, dc)
|
||||
case string(types.AskTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.AskTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.AskTypeNewTask):
|
||||
return h.handleNewTask(msg, dc, timestamp)
|
||||
return h.handleNewTask(msg, dc)
|
||||
case string(types.AskTypeCondense):
|
||||
return h.handleCondense(msg, dc, timestamp)
|
||||
return h.handleCondense(msg, dc)
|
||||
case string(types.AskTypeReportBug):
|
||||
return h.handleReportBug(msg, dc, timestamp)
|
||||
return h.handleReportBug(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFollowup handles followup questions
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var question string
|
||||
var options []string
|
||||
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Use ToolRenderer for unified rendering
|
||||
header := dc.ToolRenderer.GenerateAskFollowupHeader()
|
||||
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
|
||||
|
||||
var askData types.AskData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil {
|
||||
question = askData.Question
|
||||
options = askData.Options
|
||||
} else {
|
||||
question = msg.Text
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
}
|
||||
}
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlanModeRespond handles plan mode responses
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var response string
|
||||
var options []string
|
||||
|
||||
// Try to parse as JSON
|
||||
type PlanModeResponse struct {
|
||||
Response string `json:"response"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
var planData PlanModeResponse
|
||||
if err := json.Unmarshal([]byte(msg.Text), &planData); err == nil {
|
||||
response = planData.Response
|
||||
options = planData.Options
|
||||
} else {
|
||||
response = msg.Text
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display options if available
|
||||
if len(options) > 0 {
|
||||
fmt.Println("\nOptions:")
|
||||
for i, option := range options {
|
||||
fmt.Printf("%d. %s\n", i+1, option)
|
||||
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header was already shown by partial stream
|
||||
// Just render the body content
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
if body != "" {
|
||||
fmt.Print(body)
|
||||
}
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
header := dc.ToolRenderer.GeneratePlanModeRespondHeader()
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommand handles command execution requests
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := msg.Text
|
||||
// Check if this command was flagged despite auto-approval settings
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Check if this command was flagged despite auto-approval settings turned on for safe commands
|
||||
hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP")
|
||||
if hasAutoApprovalConflict {
|
||||
command = strings.TrimSuffix(command, "REQ_APP")
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command))
|
||||
|
||||
if hasAutoApprovalConflict {
|
||||
fmt.Printf("\nThe model has determined this command requires explicit approval.\n")
|
||||
} else {
|
||||
fmt.Printf("\nApproval required for this command.\n")
|
||||
}
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output requests
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
commandOutput := msg.Text
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommandOutput: %w", err)
|
||||
}
|
||||
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
|
||||
fmt.Printf("\nApprove to proceed while this command runs in the background.\n")
|
||||
fmt.Printf("%s", rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCompletionResult handles completion result requests
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTool handles tool execution requests
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse tool message
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
// Fallback to simple display
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
// renderToolMessage renders a tool message with appropriate formatting
|
||||
func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path))
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
|
||||
fmt.Printf("Preview: %s\n", preview)
|
||||
}
|
||||
|
||||
fmt.Printf("\nApproval required.\n")
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAPIReqFailed handles API request failures
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text))
|
||||
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleResumeTask handles resume task requests
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.")
|
||||
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Resuming interrupted task.", true)
|
||||
}
|
||||
|
||||
// handleResumeCompletedTask handles resume completed task requests
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.")
|
||||
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Resuming completed task.", true)
|
||||
}
|
||||
|
||||
// handleMistakeLimitReached handles mistake limit reached
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text))
|
||||
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleAutoApprovalMaxReached handles auto-approval max reached
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text))
|
||||
func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch requests
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := strings.TrimSpace(msg.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage requests
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse MCP server usage request
|
||||
type McpServerRequest struct {
|
||||
ServerName string `json:"serverName"`
|
||||
@@ -292,7 +217,7 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
|
||||
var mcpReq McpServerRequest
|
||||
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text)
|
||||
return dc.Renderer.RenderMessage("MCP", msg.Text, true)
|
||||
}
|
||||
|
||||
var operation string
|
||||
@@ -305,22 +230,22 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont
|
||||
}
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName))
|
||||
return dc.Renderer.RenderMessage("MCP",
|
||||
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
|
||||
}
|
||||
|
||||
// handleNewTask handles new task creation requests
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text))
|
||||
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleCondense handles conversation condensing requests
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text))
|
||||
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleReportBug handles bug report requests
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var bugData struct {
|
||||
Title string `json:"title"`
|
||||
WhatHappened string `json:"what_happened"`
|
||||
@@ -330,10 +255,10 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text))
|
||||
return dc.Renderer.RenderMessage("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true)
|
||||
}
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:")
|
||||
err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleReportBug: %w", err)
|
||||
}
|
||||
@@ -349,6 +274,6 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext
|
||||
}
|
||||
|
||||
// handleDefault handles unknown ASK message types
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text)
|
||||
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@ type MessageHandler interface {
|
||||
|
||||
// DisplayContext provides context and utilities for message handlers
|
||||
type DisplayContext struct {
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
State *types.ConversationState
|
||||
Renderer *display.Renderer
|
||||
ToolRenderer *display.ToolRenderer
|
||||
IsLast bool
|
||||
IsPartial bool
|
||||
Verbose bool
|
||||
MessageIndex int
|
||||
IsStreamingMode bool
|
||||
IsInteractive bool
|
||||
}
|
||||
|
||||
// BaseHandler provides common functionality for message handlers
|
||||
@@ -94,15 +97,13 @@ func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) er
|
||||
|
||||
// handleDefault provides default handling for unrecognized messages
|
||||
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
timestamp := msg.GetTimestamp()
|
||||
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefix := "RESPONSE:"
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
|
||||
}
|
||||
|
||||
// GetHandlers returns all registered handlers
|
||||
|
||||
@@ -31,269 +31,287 @@ func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
|
||||
switch msg.Say {
|
||||
case string(types.SayTypeTask):
|
||||
return h.handleTask(msg, dc, timestamp)
|
||||
return h.handleTask(msg, dc)
|
||||
case string(types.SayTypeError):
|
||||
return h.handleError(msg, dc, timestamp)
|
||||
return h.handleError(msg, dc)
|
||||
case string(types.SayTypeAPIReqStarted):
|
||||
return h.handleAPIReqStarted(msg, dc, timestamp)
|
||||
return h.handleAPIReqStarted(msg, dc)
|
||||
case string(types.SayTypeAPIReqFinished):
|
||||
return h.handleAPIReqFinished(msg, dc, timestamp)
|
||||
return h.handleAPIReqFinished(msg, dc)
|
||||
case string(types.SayTypeText):
|
||||
return h.handleText(msg, dc, timestamp)
|
||||
return h.handleText(msg, dc)
|
||||
case string(types.SayTypeReasoning):
|
||||
return h.handleReasoning(msg, dc, timestamp)
|
||||
return h.handleReasoning(msg, dc)
|
||||
case string(types.SayTypeCompletionResult):
|
||||
return h.handleCompletionResult(msg, dc, timestamp)
|
||||
return h.handleCompletionResult(msg, dc)
|
||||
case string(types.SayTypeUserFeedback):
|
||||
return h.handleUserFeedback(msg, dc, timestamp)
|
||||
return h.handleUserFeedback(msg, dc)
|
||||
case string(types.SayTypeUserFeedbackDiff):
|
||||
return h.handleUserFeedbackDiff(msg, dc, timestamp)
|
||||
return h.handleUserFeedbackDiff(msg, dc)
|
||||
case string(types.SayTypeAPIReqRetried):
|
||||
return h.handleAPIReqRetried(msg, dc, timestamp)
|
||||
return h.handleAPIReqRetried(msg, dc)
|
||||
case string(types.SayTypeCommand):
|
||||
return h.handleCommand(msg, dc, timestamp)
|
||||
return h.handleCommand(msg, dc)
|
||||
case string(types.SayTypeCommandOutput):
|
||||
return h.handleCommandOutput(msg, dc, timestamp)
|
||||
return h.handleCommandOutput(msg, dc)
|
||||
case string(types.SayTypeTool):
|
||||
return h.handleTool(msg, dc, timestamp)
|
||||
return h.handleTool(msg, dc)
|
||||
case string(types.SayTypeShellIntegrationWarning):
|
||||
return h.handleShellIntegrationWarning(msg, dc, timestamp)
|
||||
return h.handleShellIntegrationWarning(msg, dc)
|
||||
case string(types.SayTypeBrowserActionLaunch):
|
||||
return h.handleBrowserActionLaunch(msg, dc, timestamp)
|
||||
return h.handleBrowserActionLaunch(msg, dc)
|
||||
case string(types.SayTypeBrowserAction):
|
||||
return h.handleBrowserAction(msg, dc, timestamp)
|
||||
return h.handleBrowserAction(msg, dc)
|
||||
case string(types.SayTypeBrowserActionResult):
|
||||
return h.handleBrowserActionResult(msg, dc, timestamp)
|
||||
return h.handleBrowserActionResult(msg, dc)
|
||||
case string(types.SayTypeMcpServerRequestStarted):
|
||||
return h.handleMcpServerRequestStarted(msg, dc, timestamp)
|
||||
return h.handleMcpServerRequestStarted(msg, dc)
|
||||
case string(types.SayTypeMcpServerResponse):
|
||||
return h.handleMcpServerResponse(msg, dc, timestamp)
|
||||
return h.handleMcpServerResponse(msg, dc)
|
||||
case string(types.SayTypeMcpNotification):
|
||||
return h.handleMcpNotification(msg, dc, timestamp)
|
||||
return h.handleMcpNotification(msg, dc)
|
||||
case string(types.SayTypeUseMcpServer):
|
||||
return h.handleUseMcpServer(msg, dc, timestamp)
|
||||
return h.handleUseMcpServer(msg, dc)
|
||||
case string(types.SayTypeDiffError):
|
||||
return h.handleDiffError(msg, dc, timestamp)
|
||||
return h.handleDiffError(msg, dc)
|
||||
case string(types.SayTypeDeletedAPIReqs):
|
||||
return h.handleDeletedAPIReqs(msg, dc, timestamp)
|
||||
return h.handleDeletedAPIReqs(msg, dc)
|
||||
case string(types.SayTypeClineignoreError):
|
||||
return h.handleClineignoreError(msg, dc, timestamp)
|
||||
return h.handleClineignoreError(msg, dc)
|
||||
case string(types.SayTypeCheckpointCreated):
|
||||
return h.handleCheckpointCreated(msg, dc, timestamp)
|
||||
case string(types.SayTypeLoadMcpDocumentation):
|
||||
return h.handleLoadMcpDocumentation(msg, dc, timestamp)
|
||||
return h.handleLoadMcpDocumentation(msg, dc)
|
||||
case string(types.SayTypeInfo):
|
||||
return h.handleInfo(msg, dc, timestamp)
|
||||
return h.handleInfo(msg, dc)
|
||||
case string(types.SayTypeTaskProgress):
|
||||
return h.handleTaskProgress(msg, dc, timestamp)
|
||||
return h.handleTaskProgress(msg, dc)
|
||||
default:
|
||||
return h.handleDefault(msg, dc, timestamp)
|
||||
return h.handleDefault(msg, dc)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTask handles task messages
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleError handles error messages
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text)
|
||||
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("ERROR", msg.Text, true)
|
||||
}
|
||||
|
||||
// handleAPIReqStarted handles API request started messages
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// Parse API request info
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", msg.Text)
|
||||
return dc.Renderer.RenderMessage("API INFO", msg.Text, true)
|
||||
}
|
||||
|
||||
// Handle different API request states
|
||||
if apiInfo.CancelReason != "" {
|
||||
if apiInfo.CancelReason == "user_cancelled" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Cancelled")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
|
||||
} else if apiInfo.CancelReason == "retries_exhausted" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
|
||||
}
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed")
|
||||
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
|
||||
}
|
||||
|
||||
if apiInfo.Cost >= 0 {
|
||||
return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo)
|
||||
return dc.Renderer.RenderAPI("request completed", &apiInfo)
|
||||
}
|
||||
|
||||
// Check for retry status
|
||||
if apiInfo.RetryStatus != nil {
|
||||
return dc.Renderer.RenderRetry(timestamp,
|
||||
return dc.Renderer.RenderRetry(
|
||||
apiInfo.RetryStatus.Attempt,
|
||||
apiInfo.RetryStatus.MaxAttempts,
|
||||
apiInfo.RetryStatus.DelaySec)
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo)
|
||||
return dc.Renderer.RenderAPI("processing request", &apiInfo)
|
||||
}
|
||||
|
||||
// handleAPIReqFinished handles API request finished messages
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
// This message type is typically not displayed as it's handled by the started message
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleText handles regular text messages
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case for the user's task input
|
||||
prefix := "ASST TEXT"
|
||||
if dc.MessageIndex == 0 {
|
||||
prefix = "USER"
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
fmt.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text)
|
||||
// Regular Cline text response
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReasoning handles reasoning messages
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "THINKING", msg.Text)
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
text := msg.Text
|
||||
|
||||
if strings.HasSuffix(text, "HAS_CHANGES") {
|
||||
text = strings.TrimSuffix(text, "HAS_CHANGES")
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "RESULT", text)
|
||||
var rendered string
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatUserMessage(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Wrap each line in backticks
|
||||
for i, line := range lines {
|
||||
if line != "" {
|
||||
lines[i] = fmt.Sprintf("`%s`", line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
|
||||
// handleUserFeedback handles user feedback messages
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text)
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]")
|
||||
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserFeedbackDiff handles user feedback diff messages
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var toolMsg types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", msg.Text)
|
||||
return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
|
||||
toolMsg.Path,
|
||||
toolMsg.Diff)
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "USER DIFF", message)
|
||||
return dc.Renderer.RenderMessage("USER DIFF", message, true)
|
||||
}
|
||||
|
||||
// handleAPIReqRetried handles API request retry messages
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request")
|
||||
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
|
||||
}
|
||||
|
||||
// handleCommand handles command execution announcements
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := strings.TrimSpace(msg.Text)
|
||||
|
||||
err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render handleCommand: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n```shell\n%s\n```\n", command)
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCommandOutput handles command output messages
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
commandOutput := msg.Text
|
||||
return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput))
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text)
|
||||
}
|
||||
|
||||
return h.renderToolMessage(&tool, dc, timestamp)
|
||||
}
|
||||
|
||||
func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error {
|
||||
switch tool.Tool {
|
||||
case string(types.ToolTypeEditedExistingFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path))
|
||||
case string(types.ToolTypeNewFileCreated):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path))
|
||||
case string(types.ToolTypeReadFile):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesTopLevel):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeListFilesRecursive):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path))
|
||||
case string(types.ToolTypeSearchFiles):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path))
|
||||
case string(types.ToolTypeWebFetch):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path))
|
||||
case string(types.ToolTypeListCodeDefinitionNames):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path))
|
||||
case string(types.ToolTypeSummarizeTask):
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation")
|
||||
default:
|
||||
dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool))
|
||||
}
|
||||
|
||||
// Skip content preview for readFile and webFetch tools
|
||||
if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) {
|
||||
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show content preview, truncating if necessary
|
||||
preview := tool.Content
|
||||
if preview != "" {
|
||||
preview = strings.TrimSpace(tool.Content)
|
||||
if len(preview) > 1000 {
|
||||
preview = preview[:1000] + "..."
|
||||
}
|
||||
fmt.Printf("Content: %s\n", preview)
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
var tool types.ToolMessage
|
||||
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
|
||||
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
fmt.Print(output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleShellIntegrationWarning handles shell integration warning messages
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.")
|
||||
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true)
|
||||
}
|
||||
|
||||
// handleBrowserActionLaunch handles browser action launch messages
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
url := msg.Text
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true)
|
||||
}
|
||||
|
||||
// handleBrowserAction handles browser action messages
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -306,27 +324,27 @@ func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayCon
|
||||
|
||||
var actionData BrowserActionData
|
||||
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text)
|
||||
return dc.Renderer.RenderMessage("BROWSER", msg.Text, true)
|
||||
}
|
||||
|
||||
// Special handling for type action
|
||||
if actionData.Action == "type" && actionData.Text != "" {
|
||||
actionText := fmt.Sprintf("type '%s'", actionData.Text)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Special handling for click action
|
||||
if actionData.Action == "click" && actionData.Coordinate != "" {
|
||||
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
|
||||
}
|
||||
|
||||
// Generic handling for all other actions
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true)
|
||||
}
|
||||
|
||||
// handleBrowserActionResult handles browser action result messages
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -340,79 +358,81 @@ func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *Disp
|
||||
|
||||
var result BrowserActionResult
|
||||
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// If we have logs, include them in the message
|
||||
if result.Logs != "" {
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs))
|
||||
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
|
||||
}
|
||||
|
||||
// Default case
|
||||
return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed")
|
||||
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
|
||||
}
|
||||
|
||||
// handleMcpServerRequestStarted handles MCP server request started messages
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server")
|
||||
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Sending request to server", true)
|
||||
}
|
||||
|
||||
// handleMcpServerResponse handles MCP server response messages
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text))
|
||||
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleMcpNotification handles MCP notification messages
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text))
|
||||
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleUseMcpServer handles MCP server usage messages
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved")
|
||||
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("MCP", "Server operation approved", true)
|
||||
}
|
||||
|
||||
// handleDiffError handles diff error messages
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.")
|
||||
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
|
||||
}
|
||||
|
||||
// handleDeletedAPIReqs handles deleted API requests messages
|
||||
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
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(timestamp, "GEN INFO", "Checkpoint restored")
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Checkpoint restored", true)
|
||||
}
|
||||
|
||||
// handleClineignoreError handles .clineignore error messages
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text))
|
||||
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
|
||||
}
|
||||
|
||||
// handleCheckpointCreated handles checkpoint created messages
|
||||
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint created")
|
||||
return dc.Renderer.RenderCheckpointMessage(timestamp, "GEN INFO", msg.Timestamp)
|
||||
}
|
||||
|
||||
// handleLoadMcpDocumentation handles load MCP documentation messages
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation")
|
||||
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("GEN INFO", "Loading MCP documentation", true)
|
||||
}
|
||||
|
||||
// handleInfo handles info messages
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTaskProgress handles task progress messages
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
if msg.Text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text))
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDefault handles unknown SAY message types
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
|
||||
return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text)
|
||||
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
|
||||
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
|
||||
}
|
||||
|
||||
+80
-70
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -58,7 +60,7 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
if killAll {
|
||||
return killAllInstances(ctx, registry)
|
||||
} else {
|
||||
return killSingleInstance(ctx, registry, args[0])
|
||||
return global.KillInstanceByAddress(ctx, registry, args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -68,62 +70,6 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killSingleInstance(ctx context.Context, registry *global.ClientRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("instance %s not found in registry", address)
|
||||
}
|
||||
|
||||
fmt.Printf("Killing instance: %s\n", address)
|
||||
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
|
||||
}
|
||||
|
||||
pid := int(processInfo.ProcessId)
|
||||
fmt.Printf("Terminating process PID %d...\n", pid)
|
||||
|
||||
// Kill the process
|
||||
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to kill process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the instance to remove itself from registry
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
|
||||
|
||||
// Update default instance if needed
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err == nil && len(instances) > 0 {
|
||||
// ensureDefaultInstance logic will handle setting a new default
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
|
||||
}
|
||||
|
||||
func killAllInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
@@ -267,14 +213,21 @@ func newInstanceListCommand() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always output a table
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
// Build instance data
|
||||
type instanceRow struct {
|
||||
address string
|
||||
status string
|
||||
version string
|
||||
lastSeen string
|
||||
pid string
|
||||
isDefault string
|
||||
}
|
||||
|
||||
var rows []instanceRow
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
isDefault = "*"
|
||||
isDefault = "✓"
|
||||
}
|
||||
|
||||
lastSeen := instance.LastSeen.Format("15:04:05")
|
||||
@@ -296,17 +249,74 @@ func newInstanceListCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
instance.Address,
|
||||
instance.Status,
|
||||
instance.Version,
|
||||
lastSeen,
|
||||
pid,
|
||||
isDefault,
|
||||
)
|
||||
rows = append(rows, instanceRow{
|
||||
address: instance.Address,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
pid: pid,
|
||||
isDefault: isDefault,
|
||||
})
|
||||
}
|
||||
|
||||
// Check output format
|
||||
if global.Config.OutputFormat == "plain" {
|
||||
// Use tabwriter for plain output
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT")
|
||||
|
||||
for _, row := range rows {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.isDefault,
|
||||
)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
} else {
|
||||
// Use markdown table for rich output
|
||||
var markdown strings.Builder
|
||||
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **DEFAULT** |\n")
|
||||
markdown.WriteString("|---------|--------|---------|-----------|-----|---------|")
|
||||
|
||||
for _, row := range rows {
|
||||
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s |",
|
||||
row.address,
|
||||
row.status,
|
||||
row.version,
|
||||
row.lastSeen,
|
||||
row.pid,
|
||||
row.isDefault,
|
||||
))
|
||||
}
|
||||
|
||||
// Render the markdown table with terminal width for nice table layout
|
||||
renderer, err := display.NewMarkdownRendererForTerminal()
|
||||
if err != nil {
|
||||
// Fallback to plain table if markdown renderer fails
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
rendered, err := renderer.Render(markdown.String())
|
||||
if err != nil {
|
||||
fmt.Println(markdown.String())
|
||||
} else {
|
||||
// Post-process to colorize status values
|
||||
rendered = strings.ReplaceAll(rendered, "SERVING", "\033[32mSERVING\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "✓", "\033[32m✓\033[0m") // Green
|
||||
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", "\033[31mNOT_SERVING\033[0m") // Red
|
||||
rendered = strings.ReplaceAll(rendered, "UNKNOWN", "\033[33mUNKNOWN\033[0m") // Yellow
|
||||
|
||||
|
||||
fmt.Print(strings.TrimLeft(rendered, "\n"))
|
||||
}
|
||||
fmt.Println("\n")
|
||||
}
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (lm *LockManager) RemoveInstanceLock(address string) error {
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address
|
||||
func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) {
|
||||
if err := lm.ensureConnection(); err != nil {
|
||||
return false, nil
|
||||
return false, err
|
||||
}
|
||||
|
||||
query := common.CountInstanceLockSQL
|
||||
|
||||
+210
-41
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
@@ -12,6 +14,17 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TaskOptions contains options for creating a task
|
||||
type TaskOptions struct {
|
||||
Images []string
|
||||
Files []string
|
||||
Workspaces []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
@@ -21,12 +34,14 @@ func NewTaskCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTaskNewCommand())
|
||||
cmd.AddCommand(newTaskOneshotCommand())
|
||||
cmd.AddCommand(newTaskCancelCommand())
|
||||
cmd.AddCommand(newTaskFollowCommand())
|
||||
cmd.AddCommand(newTaskSendCommand())
|
||||
cmd.AddCommand(NewTaskSendCommand())
|
||||
cmd.AddCommand(newTaskViewCommand())
|
||||
cmd.AddCommand(newTaskListCommand())
|
||||
cmd.AddCommand(newTaskResumeCommand())
|
||||
cmd.AddCommand(newTaskRestoreCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -47,7 +62,7 @@ func ensureTaskManager(ctx context.Context, address string) error {
|
||||
instanceAddress = address
|
||||
} else {
|
||||
// Ensure default instance exists
|
||||
if err := ensureDefaultInstance(ctx); err != nil {
|
||||
if err := global.EnsureDefaultInstance(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ensure default instance: %w", err)
|
||||
}
|
||||
taskManager, err = task.NewManagerForDefault(ctx)
|
||||
@@ -78,38 +93,15 @@ func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
// ensureDefaultInstance ensures a default instance exists
|
||||
func ensureDefaultInstance(ctx context.Context) error {
|
||||
if global.Clients == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
// Check if we have any instances in the registry
|
||||
registry := global.Clients.GetRegistry()
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No default instance, start a new one
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
|
||||
// Set the new instance as default
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to set default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
wait bool
|
||||
workspaces []string
|
||||
address string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -145,20 +137,21 @@ func newTaskNewCommand() *cobra.Command {
|
||||
fmt.Printf("Mode set to: %s\n", mode)
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
|
||||
// Will append to the -s settings to be parsed by the settings parser logic.
|
||||
// If the yoloMode is also set in the settings, this will override that, since it will be set last.
|
||||
if yolo {
|
||||
settings = append(settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces)
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces, settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n", taskID)
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
// Wait for completion if requested
|
||||
if wait {
|
||||
fmt.Println("Following task conversation...")
|
||||
return taskManager.FollowConversation(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -166,10 +159,76 @@ func newTaskNewCommand() *cobra.Command {
|
||||
|
||||
cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
|
||||
cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
|
||||
cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion")
|
||||
cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)")
|
||||
cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s aws-region=us-west-2 -s mode=act)")
|
||||
cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
|
||||
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
|
||||
}
|
||||
@@ -202,7 +261,7 @@ func newTaskCancelCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskSendCommand() *cobra.Command {
|
||||
func NewTaskSendCommand() *cobra.Command {
|
||||
var (
|
||||
images []string
|
||||
files []string
|
||||
@@ -288,7 +347,7 @@ func newTaskFollowCommand() *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.`,
|
||||
Long: `Follow the current task conversation, displaying new messages as they arrive in real-time. Interactive input is enabled by default.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
@@ -297,9 +356,7 @@ func newTaskFollowCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
|
||||
return taskManager.FollowConversation(ctx)
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -402,6 +459,60 @@ func newTaskResumeCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTaskRestoreCommand() *cobra.Command {
|
||||
var (
|
||||
restoreType string
|
||||
address string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore <checkpoint-id>",
|
||||
Short: "Restore task to a specific checkpoint",
|
||||
Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
checkpointID := args[0]
|
||||
|
||||
// Convert checkpoint ID string to int64
|
||||
id, err := strconv.ParseInt(checkpointID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID)
|
||||
}
|
||||
|
||||
validTypes := []string{"task", "workspace", "taskAndWorkspace"}
|
||||
if !slices.Contains(validTypes, restoreType) {
|
||||
return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType)
|
||||
}
|
||||
|
||||
// Ensure task manager is initialized
|
||||
if err := ensureTaskManager(ctx, address); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate checkpoint exists before attempting restore
|
||||
if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance())
|
||||
fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType)
|
||||
|
||||
if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Checkpoint restored successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)")
|
||||
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
|
||||
func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
var content strings.Builder
|
||||
@@ -442,3 +553,61 @@ func CleanupTaskManager() {
|
||||
taskManager.Cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set mode to plan by default if not specified
|
||||
if opts.Mode == "" {
|
||||
opts.Mode = "plan"
|
||||
}
|
||||
|
||||
// Set mode if provided
|
||||
if opts.Mode != "" {
|
||||
if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil {
|
||||
return fmt.Errorf("failed to set mode: %w", err)
|
||||
}
|
||||
fmt.Printf("Mode set to: %s\n", opts.Mode)
|
||||
}
|
||||
|
||||
// Inject yolo_mode_toggled setting if --yolo flag is set
|
||||
if opts.Yolo {
|
||||
opts.Settings = append(opts.Settings, "yolo_mode_toggled=true")
|
||||
}
|
||||
|
||||
// Create the task
|
||||
taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Workspaces, opts.Settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Task created successfully with ID: %s\n\n", taskID)
|
||||
|
||||
// Immediately follow the conversation in interactive mode
|
||||
return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// InputHandler manages interactive user input during follow mode
|
||||
type InputHandler struct {
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewInputHandler creates a new input handler
|
||||
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
|
||||
return &InputHandler{
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins monitoring for input opportunities
|
||||
func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
ih.mu.Lock()
|
||||
ih.isRunning = false
|
||||
ih.mu.Unlock()
|
||||
ih.pollTicker.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ih.pollTicker.C:
|
||||
// First check if approval is needed
|
||||
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
// Send approval response
|
||||
approveStr := "false"
|
||||
if approved {
|
||||
approveStr = "true"
|
||||
}
|
||||
|
||||
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
|
||||
fmt.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we can send a regular message
|
||||
sendDisabled, err := ih.manager.CheckSendDisabled(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckSendDisabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If send is enabled (not disabled), show prompt
|
||||
if !sendDisabled {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
|
||||
if shouldSend {
|
||||
// Check for mode switch commands first
|
||||
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
|
||||
if isModeSwitch {
|
||||
// Switch mode
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
fmt.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\nSwitched to %s mode\n", newMode)
|
||||
|
||||
// If there's remaining message, use it as the new message to send
|
||||
if remainingMessage != "" {
|
||||
message = remainingMessage
|
||||
} else {
|
||||
// No message to send, just mode switch
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
if handled := ih.handleSpecialCommand(ctx, message); handled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the message
|
||||
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
|
||||
fmt.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
} else {
|
||||
ih.coordinator.SetInputAllowed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// promptForInput displays an interactive prompt and waits for user input
|
||||
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
var message string
|
||||
|
||||
// Get current mode and format title with color
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
// ANSI color codes
|
||||
yellow := "\033[33m" // Yellow for plan mode
|
||||
blue := "\033[34m" // Blue for act mode
|
||||
indigo := "\033[38;5;99m" // Indigo (huh default title color) - approximation of #7571F9
|
||||
bold := "\033[1m" // Bold
|
||||
reset := "\033[0m" // Reset
|
||||
|
||||
var coloredMode string
|
||||
if currentMode == "plan" {
|
||||
coloredMode = fmt.Sprintf("%s[plan mode]%s", yellow, reset)
|
||||
} else {
|
||||
coloredMode = fmt.Sprintf("%s[act mode]%s", blue, reset)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%s %s%sCline is ready for your message%s", coloredMode, bold, indigo, reset)
|
||||
|
||||
// Create multiline text area form using huh
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title(title).
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&message),
|
||||
),
|
||||
)
|
||||
|
||||
// Run the form
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
message = strings.TrimSpace(message)
|
||||
|
||||
// If empty, user just wants to keep watching
|
||||
if message == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return message, true, nil
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
// Returns (approved, message, error)
|
||||
// Note: The approval details are already shown by segment streamer / state stream
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
// Show selection menu (approval details already displayed by other handlers)
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Let Cline use this tool?").
|
||||
Options(
|
||||
huh.NewOption("Yes", "yes"),
|
||||
huh.NewOption("Yes, with feedback", "yes_feedback"),
|
||||
huh.NewOption("No", "no"),
|
||||
huh.NewOption("No, with feedback", "no_feedback"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
// Check if feedback is needed
|
||||
needsFeedback := choice == "yes_feedback" || choice == "no_feedback"
|
||||
approved := choice == "yes" || choice == "yes_feedback"
|
||||
|
||||
var feedback string
|
||||
if needsFeedback {
|
||||
// Show multiline text area for feedback
|
||||
feedbackForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Your feedback").
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&feedback),
|
||||
),
|
||||
)
|
||||
|
||||
err := feedbackForm.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
feedback = strings.TrimSpace(feedback)
|
||||
}
|
||||
|
||||
return approved, feedback, nil
|
||||
}
|
||||
|
||||
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
|
||||
// Returns: (newMode, remainingMessage, isModeSwitch)
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
// Extract remaining message after /plan
|
||||
remaining := strings.TrimSpace(trimmed[5:]) // Remove "/plan"
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
// Extract remaining message after /act
|
||||
remaining := strings.TrimSpace(trimmed[4:]) // Remove "/act"
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
return "", message, false
|
||||
}
|
||||
|
||||
// handleSpecialCommand processes special commands like /cancel, /exit
|
||||
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(message)) {
|
||||
case "/cancel":
|
||||
fmt.Println("\nCancelling task...")
|
||||
if err := ih.manager.CancelTask(ctx); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
fmt.Println("\nExiting follow mode...")
|
||||
// This will be handled by context cancellation
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the input handler
|
||||
func (ih *InputHandler) Stop() {
|
||||
ih.mu.Lock()
|
||||
defer ih.mu.Unlock()
|
||||
if ih.pollTicker != nil {
|
||||
ih.pollTicker.Stop()
|
||||
}
|
||||
ih.isRunning = false
|
||||
}
|
||||
|
||||
// IsRunning returns whether the input handler is currently running
|
||||
func (ih *InputHandler) IsRunning() bool {
|
||||
ih.mu.RLock()
|
||||
defer ih.mu.RUnlock()
|
||||
return ih.isRunning
|
||||
}
|
||||
+388
-60
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
@@ -22,14 +25,19 @@ type Manager struct {
|
||||
clientAddress string
|
||||
state *types.ConversationState
|
||||
renderer *display.Renderer
|
||||
toolRenderer *display.ToolRenderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
isStreamingMode bool
|
||||
isInteractive bool
|
||||
currentMode string // "plan" or "act"
|
||||
}
|
||||
|
||||
// NewManager creates a new task manager
|
||||
func NewManager(client *client.ClineClient) *Manager {
|
||||
state := types.NewConversationState()
|
||||
renderer := display.NewRenderer()
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
toolRenderer := display.NewToolRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
|
||||
streamingDisplay := display.NewStreamingDisplay(state, renderer)
|
||||
|
||||
// Create handler registry and register handlers
|
||||
@@ -42,8 +50,10 @@ func NewManager(client *client.ClineClient) *Manager {
|
||||
clientAddress: "", // Will be set when client is provided
|
||||
state: state,
|
||||
renderer: renderer,
|
||||
toolRenderer: toolRenderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
currentMode: "plan", // Default mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +116,7 @@ func (m *Manager) GetCurrentInstance() string {
|
||||
}
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) {
|
||||
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string, settingsFlags []string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -121,6 +131,9 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [
|
||||
if len(workspacePaths) > 0 {
|
||||
m.renderer.RenderDebug("Workspaces: %v", workspacePaths)
|
||||
}
|
||||
if len(settingsFlags) > 0 {
|
||||
m.renderer.RenderDebug("Settings: %v", settingsFlags)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's an active task and cancel it first
|
||||
@@ -128,11 +141,22 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [
|
||||
return "", fmt.Errorf("failed to cancel existing task: %w", err)
|
||||
}
|
||||
|
||||
// Parse task settings if provided
|
||||
var taskSettings *cline.Settings
|
||||
if len(settingsFlags) > 0 {
|
||||
var err error
|
||||
taskSettings, _, err = ParseTaskSettings(settingsFlags)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse task settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create task request
|
||||
req := &cline.NewTaskRequest{
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Text: prompt,
|
||||
Images: images,
|
||||
Files: files,
|
||||
TaskSettings: taskSettings,
|
||||
}
|
||||
|
||||
resp, err := m.client.Task.NewTask(ctx, req)
|
||||
@@ -184,11 +208,38 @@ func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
|
||||
fmt.Println("Cancelled existing task to start new one")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCheckpointExists checks if a checkpoint ID is valid
|
||||
func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int64) error {
|
||||
// Get current state
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
// Extract messages
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
// Find and validate the checkpoint message
|
||||
for _, msg := range messages {
|
||||
if msg.Timestamp == checkpointID {
|
||||
if msg.Say != string(types.SayTypeCheckpointCreated) {
|
||||
return fmt.Errorf("timestamp %d is not a checkpoint (type: %s)", checkpointID, msg.Type)
|
||||
}
|
||||
return nil // Valid checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID)
|
||||
}
|
||||
|
||||
// CheckSendDisabled determines if we can send a message to the current task
|
||||
// We duplicate the logic from buttonConfig::getButtonConfig
|
||||
func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
@@ -259,6 +310,50 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CheckNeedsApproval determines if the current task is waiting for approval
|
||||
// Returns (needsApproval, lastMessage, error)
|
||||
func (m *Manager) CheckNeedsApproval(ctx context.Context) (bool, *types.ClineMessage, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("failed to get latest state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Use final message to check if approval is needed
|
||||
lastMessage := messages[len(messages)-1]
|
||||
|
||||
// Only check non-partial ask messages
|
||||
if lastMessage.Partial {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Check if this is an approval-required ask type
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
approvalTypes := []string{
|
||||
string(types.AskTypeTool),
|
||||
string(types.AskTypeCommand),
|
||||
string(types.AskTypeBrowserActionLaunch),
|
||||
string(types.AskTypeUseMcpServer),
|
||||
}
|
||||
|
||||
for _, approvalType := range approvalTypes {
|
||||
if lastMessage.Ask == approvalType {
|
||||
return true, lastMessage, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// SendMessage sends a followup message to the current task
|
||||
func (m *Manager) SendMessage(ctx context.Context, message string, images, files []string, approve string) error {
|
||||
responseType := "messageResponse"
|
||||
@@ -449,6 +544,27 @@ func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreCheckpoint restores the task to a specific checkpoint
|
||||
func (m *Manager) RestoreCheckpoint(ctx context.Context, checkpointID int64, restoreType string) error {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Restoring checkpoint: %d (type: %s)", checkpointID, restoreType)
|
||||
}
|
||||
|
||||
// Create the checkpoint restore request
|
||||
req := &cline.CheckpointRestoreRequest{
|
||||
Metadata: &cline.Metadata{},
|
||||
Number: checkpointID,
|
||||
RestoreType: restoreType,
|
||||
}
|
||||
|
||||
_, err := m.client.Checkpoints.CheckpointRestore(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore checkpoint %d: %w", checkpointID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTask cancels the current task
|
||||
func (m *Manager) CancelTask(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
@@ -516,6 +632,11 @@ func (m *Manager) GatherFinalSummary(ctx context.Context) error {
|
||||
|
||||
// ShowConversation displays the current conversation
|
||||
func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
// Disable streaming mode for static view
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = false
|
||||
m.mu.Unlock()
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -536,16 +657,35 @@ func (m *Manager) ShowConversation(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display messages
|
||||
for i, msg := range messages {
|
||||
if msg.Partial {
|
||||
continue
|
||||
}
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string, interactive bool) error {
|
||||
// Enable streaming mode
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = true
|
||||
m.isInteractive = interactive
|
||||
m.mu.Unlock()
|
||||
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Using instance: %s*\n*Press Ctrl+C to exit*", instanceAddress)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
} else {
|
||||
fmt.Printf("Using instance: %s\n", instanceAddress)
|
||||
if interactive {
|
||||
fmt.Println("Following task conversation in interactive mode... (Press Ctrl+C to exit)")
|
||||
} else {
|
||||
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -561,21 +701,53 @@ func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
|
||||
fmt.Println("\n--- Live updates ---")
|
||||
|
||||
// Start both streams concurrently
|
||||
errChan := make(chan error, 2)
|
||||
errChan := make(chan error, 3)
|
||||
|
||||
if global.Config.OutputFormat == "json" {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
} else {
|
||||
go m.handleStateStream(ctx, coordinator, errChan, nil)
|
||||
go m.handlePartialMessageStream(ctx, coordinator, errChan)
|
||||
|
||||
// Start input handler if interactive mode is enabled
|
||||
if interactive {
|
||||
inputHandler := NewInputHandler(m, coordinator, cancel)
|
||||
go inputHandler.Start(ctx, errChan)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ctrl+C signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
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)
|
||||
}
|
||||
// Don't cancel main context - stay in follow mode
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for either stream to error or context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Check if this was a user-initiated cancellation (Ctrl+C)
|
||||
// Return nil for clean exit instead of context.Canceled error
|
||||
if ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
case err := <-errChan:
|
||||
cancel()
|
||||
@@ -585,7 +757,12 @@ func (m *Manager) FollowConversation(ctx context.Context) error {
|
||||
|
||||
// FollowConversationUntilCompletion streams conversation updates until task completion
|
||||
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)")
|
||||
// Enable streaming mode
|
||||
m.mu.Lock()
|
||||
m.isStreamingMode = true
|
||||
m.mu.Unlock()
|
||||
|
||||
fmt.Println("Following task conversation until completion... (Press Ctrl+C to exit)")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -593,10 +770,10 @@ func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error {
|
||||
// Create stream coordinator
|
||||
coordinator := NewStreamCoordinator()
|
||||
|
||||
// Get current message count without displaying history
|
||||
totalMessageCount, err := m.getCurrentMessageCount(ctx)
|
||||
// Load history first
|
||||
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
|
||||
if err != nil {
|
||||
m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err)
|
||||
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
|
||||
totalMessageCount = 0
|
||||
}
|
||||
coordinator.SetConversationTurnStartIndex(totalMessageCount)
|
||||
@@ -710,7 +887,9 @@ 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
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@@ -726,6 +905,9 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat
|
||||
|
||||
// processStateUpdate processes state updates and supports logic for handling task competion markers
|
||||
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
|
||||
// Update current mode from state
|
||||
m.updateMode(stateUpdate.StateJson)
|
||||
|
||||
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -749,27 +931,115 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
foundCompletion = true
|
||||
}
|
||||
|
||||
// Currently handling a subset of message types for displaying
|
||||
switch {
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
if !coordinator.IsProcessedInCurrentTurn("user_msg") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("user_msg")
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
if !coordinator.IsProcessedInCurrentTurn("checkpoint") {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn("checkpoint")
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeAPIReqStarted):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
case msg.Ask == string(types.AskTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,6 +1060,10 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
m.streamingDisplay.FreezeActiveSegment()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -810,7 +1084,7 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
msg.Type, msg.Partial, len(msg.Text))
|
||||
|
||||
// Handle the message with streaming support for de-dupping
|
||||
if err := m.handleStreamingMessage(msg); err != nil {
|
||||
if err := m.handleStreamingMessage(msg, coordinator); err != nil {
|
||||
m.renderer.RenderDebug("Error handling streaming message: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -818,17 +1092,20 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S
|
||||
}
|
||||
|
||||
// handleStreamingMessage handles a streaming message
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error {
|
||||
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *StreamCoordinator) error {
|
||||
// Debug: Always log what we're processing
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
|
||||
// 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
|
||||
return m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -846,12 +1123,20 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
|
||||
if global.Config.OutputFormat == "json" {
|
||||
return m.outputMessageAsJSON(msg)
|
||||
} else {
|
||||
m.mu.RLock()
|
||||
isStreaming := m.isStreamingMode
|
||||
isInteractive := m.isInteractive
|
||||
m.mu.RUnlock()
|
||||
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
ToolRenderer: m.toolRenderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
MessageIndex: messageIndex,
|
||||
IsStreamingMode: isStreaming,
|
||||
IsInteractive: isInteractive,
|
||||
}
|
||||
|
||||
return m.handlerRegistry.Handle(msg, dc)
|
||||
@@ -869,21 +1154,6 @@ func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentMessageCount gets the current message count without displaying messages
|
||||
func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) {
|
||||
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
messages, err := m.extractMessagesFromState(state.StateJson)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to extract messages: %w", err)
|
||||
}
|
||||
|
||||
return len(messages), nil
|
||||
}
|
||||
|
||||
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
|
||||
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
|
||||
// Get the latest state which contains messages
|
||||
@@ -908,18 +1178,35 @@ func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error)
|
||||
totalMessages := len(messages)
|
||||
startIndex := 0
|
||||
|
||||
|
||||
if totalMessages > maxHistoryMessages {
|
||||
startIndex = totalMessages - maxHistoryMessages
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Conversation history (%d of %d messages)*", maxHistoryMessages, totalMessages)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
if global.Config.OutputFormat != "plain" {
|
||||
markdown := fmt.Sprintf("*Conversation history (%d messages)*", totalMessages)
|
||||
rendered := m.renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n\n", rendered)
|
||||
} else {
|
||||
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// Display recent messages
|
||||
|
||||
|
||||
for i := startIndex; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
|
||||
// Display the message
|
||||
if msg.Partial {
|
||||
continue
|
||||
}
|
||||
|
||||
m.displayMessage(msg, false, false, i)
|
||||
}
|
||||
|
||||
@@ -937,6 +1224,47 @@ func (m *Manager) GetState() *types.ConversationState {
|
||||
return m.state
|
||||
}
|
||||
|
||||
// GetClient returns the underlying ClineClient for direct gRPC calls
|
||||
func (m *Manager) GetClient() *client.ClineClient {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.client
|
||||
}
|
||||
|
||||
// GetRenderer returns the renderer for formatting output
|
||||
func (m *Manager) GetRenderer() *display.Renderer {
|
||||
return m.renderer
|
||||
}
|
||||
|
||||
// GetCurrentMode returns the current plan/act mode
|
||||
func (m *Manager) GetCurrentMode() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.currentMode
|
||||
}
|
||||
|
||||
// extractModeFromState extracts the current mode from state JSON
|
||||
func (m *Manager) extractModeFromState(stateJson string) string {
|
||||
var rawState map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
|
||||
return m.currentMode // Return current mode if parsing fails
|
||||
}
|
||||
|
||||
if mode, ok := rawState["mode"].(string); ok {
|
||||
return mode
|
||||
}
|
||||
|
||||
return m.currentMode // Return current mode if not found in state
|
||||
}
|
||||
|
||||
// updateMode updates the current mode from state
|
||||
func (m *Manager) updateMode(stateJson string) {
|
||||
mode := m.extractModeFromState(stateJson)
|
||||
m.mu.Lock()
|
||||
m.currentMode = mode
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (m *Manager) Cleanup() {
|
||||
// Clean up streaming display resources if needed
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
|
||||
func ParseTaskSettings(settingsFlags []string) (*cline.Settings, *cline.Secrets, error) {
|
||||
if len(settingsFlags) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
settings := &cline.Settings{}
|
||||
secrets := &cline.Secrets{}
|
||||
nestedSettings := make(map[string]map[string]string)
|
||||
|
||||
for _, flag := range settingsFlags {
|
||||
// Parse key=value
|
||||
parts := strings.SplitN(flag, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, fmt.Errorf("invalid setting format '%s': expected key=value", flag)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Convert kebab-case to snake_case
|
||||
key = kebabToSnake(key)
|
||||
|
||||
// Check if this is a nested setting (contains a dot)
|
||||
if strings.Contains(key, ".") {
|
||||
dotParts := strings.SplitN(key, ".", 2)
|
||||
parentField := dotParts[0]
|
||||
childField := dotParts[1]
|
||||
|
||||
if nestedSettings[parentField] == nil {
|
||||
nestedSettings[parentField] = make(map[string]string)
|
||||
}
|
||||
nestedSettings[parentField][childField] = value
|
||||
} else {
|
||||
// Check if it's a secret field first, then settings field
|
||||
if err := setSecretField(secrets, key, value); err == nil {
|
||||
// Successfully set as secret, continue
|
||||
continue
|
||||
}
|
||||
// Not a secret, try as a settings field
|
||||
if err := setSimpleField(settings, key, value); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting field '%s': %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested settings
|
||||
for parentField, childFields := range nestedSettings {
|
||||
if err := setNestedField(settings, parentField, childFields); err != nil {
|
||||
return nil, nil, fmt.Errorf("error setting nested field '%s': %w", parentField, err)
|
||||
}
|
||||
}
|
||||
|
||||
return settings, secrets, nil
|
||||
}
|
||||
|
||||
// kebabToSnake converts kebab-case to snake_case
|
||||
func kebabToSnake(s string) string {
|
||||
return strings.ReplaceAll(s, "-", "_")
|
||||
}
|
||||
|
||||
// Pointer helper functions for optional protobuf fields
|
||||
func strPtr(s string) *string { return &s }
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
func float64Ptr(f float64) *float64 { return &f }
|
||||
|
||||
// setSimpleField sets a simple (non-nested) field on Settings
|
||||
func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
switch key {
|
||||
// String fields
|
||||
case "aws_region":
|
||||
settings.AwsRegion = strPtr(value)
|
||||
case "aws_bedrock_endpoint":
|
||||
settings.AwsBedrockEndpoint = strPtr(value)
|
||||
case "aws_profile":
|
||||
settings.AwsProfile = strPtr(value)
|
||||
case "aws_authentication":
|
||||
settings.AwsAuthentication = strPtr(value)
|
||||
case "vertex_project_id":
|
||||
settings.VertexProjectId = strPtr(value)
|
||||
case "vertex_region":
|
||||
settings.VertexRegion = strPtr(value)
|
||||
case "requesty_base_url":
|
||||
settings.RequestyBaseUrl = strPtr(value)
|
||||
case "open_ai_base_url":
|
||||
settings.OpenAiBaseUrl = strPtr(value)
|
||||
case "ollama_base_url":
|
||||
settings.OllamaBaseUrl = strPtr(value)
|
||||
case "ollama_api_options_ctx_num":
|
||||
settings.OllamaApiOptionsCtxNum = strPtr(value)
|
||||
case "lm_studio_base_url":
|
||||
settings.LmStudioBaseUrl = strPtr(value)
|
||||
case "lm_studio_max_tokens":
|
||||
settings.LmStudioMaxTokens = strPtr(value)
|
||||
case "anthropic_base_url":
|
||||
settings.AnthropicBaseUrl = strPtr(value)
|
||||
case "gemini_base_url":
|
||||
settings.GeminiBaseUrl = strPtr(value)
|
||||
case "azure_api_version":
|
||||
settings.AzureApiVersion = strPtr(value)
|
||||
case "open_router_provider_sorting":
|
||||
settings.OpenRouterProviderSorting = strPtr(value)
|
||||
case "lite_llm_base_url":
|
||||
settings.LiteLlmBaseUrl = strPtr(value)
|
||||
case "qwen_api_line":
|
||||
settings.QwenApiLine = strPtr(value)
|
||||
case "moonshot_api_line":
|
||||
settings.MoonshotApiLine = strPtr(value)
|
||||
case "zai_api_line":
|
||||
settings.ZaiApiLine = strPtr(value)
|
||||
case "telemetry_setting":
|
||||
settings.TelemetrySetting = strPtr(value)
|
||||
case "asksage_api_url":
|
||||
settings.AsksageApiUrl = strPtr(value)
|
||||
case "default_terminal_profile":
|
||||
settings.DefaultTerminalProfile = strPtr(value)
|
||||
case "sap_ai_core_token_url":
|
||||
settings.SapAiCoreTokenUrl = strPtr(value)
|
||||
case "sap_ai_core_base_url":
|
||||
settings.SapAiCoreBaseUrl = strPtr(value)
|
||||
case "sap_ai_resource_group":
|
||||
settings.SapAiResourceGroup = strPtr(value)
|
||||
case "claude_code_path":
|
||||
settings.ClaudeCodePath = strPtr(value)
|
||||
case "qwen_code_oauth_path":
|
||||
settings.QwenCodeOauthPath = strPtr(value)
|
||||
case "preferred_language":
|
||||
settings.PreferredLanguage = strPtr(value)
|
||||
case "custom_prompt":
|
||||
settings.CustomPrompt = strPtr(value)
|
||||
case "dify_base_url":
|
||||
settings.DifyBaseUrl = strPtr(value)
|
||||
case "oca_base_url":
|
||||
settings.OcaBaseUrl = strPtr(value)
|
||||
case "plan_mode_api_model_id":
|
||||
settings.PlanModeApiModelId = strPtr(value)
|
||||
case "plan_mode_reasoning_effort":
|
||||
settings.PlanModeReasoningEffort = strPtr(value)
|
||||
case "plan_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.PlanModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "plan_mode_open_router_model_id":
|
||||
settings.PlanModeOpenRouterModelId = strPtr(value)
|
||||
case "plan_mode_open_ai_model_id":
|
||||
settings.PlanModeOpenAiModelId = strPtr(value)
|
||||
case "plan_mode_ollama_model_id":
|
||||
settings.PlanModeOllamaModelId = strPtr(value)
|
||||
case "plan_mode_lm_studio_model_id":
|
||||
settings.PlanModeLmStudioModelId = strPtr(value)
|
||||
case "plan_mode_lite_llm_model_id":
|
||||
settings.PlanModeLiteLlmModelId = strPtr(value)
|
||||
case "plan_mode_requesty_model_id":
|
||||
settings.PlanModeRequestyModelId = strPtr(value)
|
||||
case "plan_mode_together_model_id":
|
||||
settings.PlanModeTogetherModelId = strPtr(value)
|
||||
case "plan_mode_fireworks_model_id":
|
||||
settings.PlanModeFireworksModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_model_id":
|
||||
settings.PlanModeSapAiCoreModelId = strPtr(value)
|
||||
case "plan_mode_sap_ai_core_deployment_id":
|
||||
settings.PlanModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "plan_mode_groq_model_id":
|
||||
settings.PlanModeGroqModelId = strPtr(value)
|
||||
case "plan_mode_baseten_model_id":
|
||||
settings.PlanModeBasetenModelId = strPtr(value)
|
||||
case "plan_mode_hugging_face_model_id":
|
||||
settings.PlanModeHuggingFaceModelId = strPtr(value)
|
||||
case "plan_mode_huawei_cloud_maas_model_id":
|
||||
settings.PlanModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "plan_mode_oca_model_id":
|
||||
settings.PlanModeOcaModelId = strPtr(value)
|
||||
case "plan_mode_vercel_ai_gateway_model_id":
|
||||
settings.PlanModeVercelAiGatewayModelId = strPtr(value)
|
||||
case "act_mode_api_model_id":
|
||||
settings.ActModeApiModelId = strPtr(value)
|
||||
case "act_mode_reasoning_effort":
|
||||
settings.ActModeReasoningEffort = strPtr(value)
|
||||
case "act_mode_aws_bedrock_custom_model_base_id":
|
||||
settings.ActModeAwsBedrockCustomModelBaseId = strPtr(value)
|
||||
case "act_mode_open_router_model_id":
|
||||
settings.ActModeOpenRouterModelId = strPtr(value)
|
||||
case "act_mode_open_ai_model_id":
|
||||
settings.ActModeOpenAiModelId = strPtr(value)
|
||||
case "act_mode_ollama_model_id":
|
||||
settings.ActModeOllamaModelId = strPtr(value)
|
||||
case "act_mode_lm_studio_model_id":
|
||||
settings.ActModeLmStudioModelId = strPtr(value)
|
||||
case "act_mode_lite_llm_model_id":
|
||||
settings.ActModeLiteLlmModelId = strPtr(value)
|
||||
case "act_mode_requesty_model_id":
|
||||
settings.ActModeRequestyModelId = strPtr(value)
|
||||
case "act_mode_together_model_id":
|
||||
settings.ActModeTogetherModelId = strPtr(value)
|
||||
case "act_mode_fireworks_model_id":
|
||||
settings.ActModeFireworksModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_model_id":
|
||||
settings.ActModeSapAiCoreModelId = strPtr(value)
|
||||
case "act_mode_sap_ai_core_deployment_id":
|
||||
settings.ActModeSapAiCoreDeploymentId = strPtr(value)
|
||||
case "act_mode_groq_model_id":
|
||||
settings.ActModeGroqModelId = strPtr(value)
|
||||
case "act_mode_baseten_model_id":
|
||||
settings.ActModeBasetenModelId = strPtr(value)
|
||||
case "act_mode_hugging_face_model_id":
|
||||
settings.ActModeHuggingFaceModelId = strPtr(value)
|
||||
case "act_mode_huawei_cloud_maas_model_id":
|
||||
settings.ActModeHuaweiCloudMaasModelId = strPtr(value)
|
||||
case "act_mode_oca_model_id":
|
||||
settings.ActModeOcaModelId = strPtr(value)
|
||||
case "act_mode_vercel_ai_gateway_model_id":
|
||||
settings.ActModeVercelAiGatewayModelId = strPtr(value)
|
||||
|
||||
// Boolean fields
|
||||
case "aws_use_cross_region_inference":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseCrossRegionInference = boolPtr(val)
|
||||
case "aws_bedrock_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsBedrockUsePromptCache = boolPtr(val)
|
||||
case "aws_use_profile":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AwsUseProfile = boolPtr(val)
|
||||
case "lite_llm_use_prompt_cache":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.LiteLlmUsePromptCache = boolPtr(val)
|
||||
case "plan_act_separate_models_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanActSeparateModelsSetting = boolPtr(val)
|
||||
case "enable_checkpoints_setting":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableCheckpointsSetting = boolPtr(val)
|
||||
case "sap_ai_core_use_orchestration_mode":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.SapAiCoreUseOrchestrationMode = boolPtr(val)
|
||||
case "strict_plan_mode_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.StrictPlanModeEnabled = boolPtr(val)
|
||||
case "yolo_mode_toggled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.YoloModeToggled = boolPtr(val)
|
||||
case "use_auto_condense":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.UseAutoCondense = boolPtr(val)
|
||||
case "plan_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "act_mode_aws_bedrock_custom_selected":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
|
||||
// Integer fields
|
||||
case "request_timeout_ms":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RequestTimeoutMs = int32Ptr(val)
|
||||
case "shell_integration_timeout":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ShellIntegrationTimeout = int32Ptr(val)
|
||||
case "terminal_output_line_limit":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.TerminalOutputLineLimit = int32Ptr(val)
|
||||
case "fireworks_model_max_completion_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxCompletionTokens = int32Ptr(val)
|
||||
case "fireworks_model_max_tokens":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.FireworksModelMaxTokens = int32Ptr(val)
|
||||
|
||||
// Int64 fields
|
||||
case "plan_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeThinkingBudgetTokens = int64Ptr(val)
|
||||
case "act_mode_thinking_budget_tokens":
|
||||
val, err := parseInt64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeThinkingBudgetTokens = int64Ptr(val)
|
||||
|
||||
// Double fields
|
||||
case "auto_condense_threshold":
|
||||
val, err := parseFloat64(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.AutoCondenseThreshold = float64Ptr(val)
|
||||
|
||||
// Enum fields
|
||||
// Note: We can use &val directly for enums because the parser functions return a new local variable.
|
||||
// This is different from using &value (the loop variable), which would cause all fields to share
|
||||
// the same memory address.
|
||||
case "openai_reasoning_effort":
|
||||
val, err := parseOpenaiReasoningEffort(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.OpenaiReasoningEffort = &val
|
||||
case "mode":
|
||||
val, err := parsePlanActMode(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Mode = &val
|
||||
case "plan_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.PlanModeApiProvider = &val
|
||||
case "act_mode_api_provider":
|
||||
val, err := parseApiProvider(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.ActModeApiProvider = &val
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setNestedField sets a nested field on Settings
|
||||
// Currently supports: auto_approval_settings, browser_settings
|
||||
func setNestedField(settings *cline.Settings, parentField string, childFields map[string]string) error {
|
||||
switch parentField {
|
||||
case "auto_approval_settings":
|
||||
if settings.AutoApprovalSettings == nil {
|
||||
settings.AutoApprovalSettings = &cline.AutoApprovalSettings{}
|
||||
}
|
||||
return setAutoApprovalSettings(settings.AutoApprovalSettings, childFields)
|
||||
|
||||
case "browser_settings":
|
||||
if settings.BrowserSettings == nil {
|
||||
settings.BrowserSettings = &cline.BrowserSettings{}
|
||||
}
|
||||
return setBrowserSettings(settings.BrowserSettings, childFields)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported nested field '%s' (complex nested types are not supported via -s flags)", parentField)
|
||||
}
|
||||
}
|
||||
|
||||
// setAutoApprovalSettings sets fields on AutoApprovalSettings
|
||||
func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.Enabled = val
|
||||
case "max_requests":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.MaxRequests = val
|
||||
case "enable_notifications":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.EnableNotifications = val
|
||||
case "actions":
|
||||
return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)")
|
||||
default:
|
||||
// Check if this is an action field (actions.*)
|
||||
if strings.HasPrefix(key, "actions.") {
|
||||
actionField := strings.TrimPrefix(key, "actions.")
|
||||
if settings.Actions == nil {
|
||||
settings.Actions = &cline.AutoApprovalActions{}
|
||||
}
|
||||
if err := setAutoApprovalAction(settings.Actions, actionField, value); err != nil {
|
||||
return err
|
||||
}
|
||||
// Continue processing other fields
|
||||
} else {
|
||||
return fmt.Errorf("unsupported auto_approval_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAutoApprovalAction sets fields on AutoApprovalActions
|
||||
func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string) error {
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "read_files":
|
||||
actions.ReadFiles = val
|
||||
case "read_files_externally":
|
||||
actions.ReadFilesExternally = val
|
||||
case "edit_files":
|
||||
actions.EditFiles = val
|
||||
case "edit_files_externally":
|
||||
actions.EditFilesExternally = val
|
||||
case "execute_safe_commands":
|
||||
actions.ExecuteSafeCommands = val
|
||||
case "execute_all_commands":
|
||||
actions.ExecuteAllCommands = val
|
||||
case "use_browser":
|
||||
actions.UseBrowser = val
|
||||
case "use_mcp":
|
||||
actions.UseMcp = val
|
||||
default:
|
||||
return fmt.Errorf("unsupported auto_approval_actions field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setBrowserSettings sets fields on BrowserSettings
|
||||
func setBrowserSettings(settings *cline.BrowserSettings, fields map[string]string) error {
|
||||
for key, value := range fields {
|
||||
switch key {
|
||||
case "viewport_width":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Width = val
|
||||
case "viewport_height":
|
||||
val, err := parseInt32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Viewport == nil {
|
||||
settings.Viewport = &cline.Viewport{}
|
||||
}
|
||||
settings.Viewport.Height = val
|
||||
case "remote_browser_host":
|
||||
settings.RemoteBrowserHost = strPtr(value)
|
||||
case "remote_browser_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.RemoteBrowserEnabled = boolPtr(val)
|
||||
case "chrome_executable_path":
|
||||
settings.ChromeExecutablePath = strPtr(value)
|
||||
case "disable_tool_use":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.DisableToolUse = boolPtr(val)
|
||||
case "custom_args":
|
||||
settings.CustomArgs = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported browser_settings field '%s'", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type parsing helpers
|
||||
func parseBool(value string) (bool, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "true", "t", "yes", "y", "1":
|
||||
return true, nil
|
||||
case "false", "f", "no", "n", "0":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value '%s': expected true/false", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt32(value string) (int32, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return int32(val), nil
|
||||
}
|
||||
|
||||
func parseInt64(value string) (int64, error) {
|
||||
val, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseFloat64(value string) (float64, error) {
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid float value '%s': %w", value, err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Enum parsing helpers
|
||||
func parseOpenaiReasoningEffort(value string) (cline.OpenaiReasoningEffort, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "low":
|
||||
return cline.OpenaiReasoningEffort_LOW, nil
|
||||
case "medium":
|
||||
return cline.OpenaiReasoningEffort_MEDIUM, nil
|
||||
case "high":
|
||||
return cline.OpenaiReasoningEffort_HIGH, nil
|
||||
default:
|
||||
return cline.OpenaiReasoningEffort_LOW, fmt.Errorf("invalid openai_reasoning_effort '%s': expected low/medium/high", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlanActMode(value string) (cline.PlanActMode, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "plan":
|
||||
return cline.PlanActMode_PLAN, nil
|
||||
case "act":
|
||||
return cline.PlanActMode_ACT, nil
|
||||
default:
|
||||
return cline.PlanActMode_ACT, fmt.Errorf("invalid mode '%s': expected plan/act", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseApiProvider(value string) (cline.ApiProvider, error) {
|
||||
lower := strings.ToLower(value)
|
||||
switch lower {
|
||||
case "anthropic":
|
||||
return cline.ApiProvider_ANTHROPIC, nil
|
||||
case "openrouter":
|
||||
return cline.ApiProvider_OPENROUTER, nil
|
||||
case "bedrock":
|
||||
return cline.ApiProvider_BEDROCK, nil
|
||||
case "vertex":
|
||||
return cline.ApiProvider_VERTEX, nil
|
||||
case "openai":
|
||||
return cline.ApiProvider_OPENAI, nil
|
||||
case "ollama":
|
||||
return cline.ApiProvider_OLLAMA, nil
|
||||
case "lmstudio":
|
||||
return cline.ApiProvider_LMSTUDIO, nil
|
||||
case "gemini":
|
||||
return cline.ApiProvider_GEMINI, nil
|
||||
case "openai_native":
|
||||
return cline.ApiProvider_OPENAI_NATIVE, nil
|
||||
case "requesty":
|
||||
return cline.ApiProvider_REQUESTY, nil
|
||||
case "together":
|
||||
return cline.ApiProvider_TOGETHER, nil
|
||||
case "deepseek":
|
||||
return cline.ApiProvider_DEEPSEEK, nil
|
||||
case "qwen":
|
||||
return cline.ApiProvider_QWEN, nil
|
||||
case "doubao":
|
||||
return cline.ApiProvider_DOUBAO, nil
|
||||
case "mistral":
|
||||
return cline.ApiProvider_MISTRAL, nil
|
||||
case "vscode_lm":
|
||||
return cline.ApiProvider_VSCODE_LM, nil
|
||||
case "cline":
|
||||
return cline.ApiProvider_CLINE, nil
|
||||
case "litellm":
|
||||
return cline.ApiProvider_LITELLM, nil
|
||||
case "nebius":
|
||||
return cline.ApiProvider_NEBIUS, nil
|
||||
case "fireworks":
|
||||
return cline.ApiProvider_FIREWORKS, nil
|
||||
case "asksage":
|
||||
return cline.ApiProvider_ASKSAGE, nil
|
||||
case "xai", "grok":
|
||||
return cline.ApiProvider_XAI, nil
|
||||
case "sambanova":
|
||||
return cline.ApiProvider_SAMBANOVA, nil
|
||||
case "cerebras":
|
||||
return cline.ApiProvider_CEREBRAS, nil
|
||||
case "groq":
|
||||
return cline.ApiProvider_GROQ, nil
|
||||
case "sapaicore", "sap_ai_core":
|
||||
return cline.ApiProvider_SAPAICORE, nil
|
||||
case "claude_code":
|
||||
return cline.ApiProvider_CLAUDE_CODE, nil
|
||||
case "moonshot":
|
||||
return cline.ApiProvider_MOONSHOT, nil
|
||||
case "huggingface":
|
||||
return cline.ApiProvider_HUGGINGFACE, nil
|
||||
case "huawei_cloud_maas":
|
||||
return cline.ApiProvider_HUAWEI_CLOUD_MAAS, nil
|
||||
case "baseten":
|
||||
return cline.ApiProvider_BASETEN, nil
|
||||
case "zai":
|
||||
return cline.ApiProvider_ZAI, nil
|
||||
case "vercel_ai_gateway":
|
||||
return cline.ApiProvider_VERCEL_AI_GATEWAY, nil
|
||||
case "qwen_code":
|
||||
return cline.ApiProvider_QWEN_CODE, nil
|
||||
case "dify":
|
||||
return cline.ApiProvider_DIFY, nil
|
||||
case "oca":
|
||||
return cline.ApiProvider_OCA, nil
|
||||
default:
|
||||
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value)
|
||||
}
|
||||
}
|
||||
|
||||
// setSecretField sets a secret field on Secrets
|
||||
// All secret fields are optional strings
|
||||
// Returns nil if field was successfully set, error otherwise
|
||||
func setSecretField(secrets *cline.Secrets, key, value string) error {
|
||||
switch key {
|
||||
case "api_key":
|
||||
secrets.ApiKey = strPtr(value)
|
||||
case "open_router_api_key":
|
||||
secrets.OpenRouterApiKey = strPtr(value)
|
||||
case "aws_access_key":
|
||||
secrets.AwsAccessKey = strPtr(value)
|
||||
case "aws_secret_key":
|
||||
secrets.AwsSecretKey = strPtr(value)
|
||||
case "aws_session_token":
|
||||
secrets.AwsSessionToken = strPtr(value)
|
||||
case "aws_bedrock_api_key":
|
||||
secrets.AwsBedrockApiKey = strPtr(value)
|
||||
case "open_ai_api_key":
|
||||
secrets.OpenAiApiKey = strPtr(value)
|
||||
case "gemini_api_key":
|
||||
secrets.GeminiApiKey = strPtr(value)
|
||||
case "open_ai_native_api_key":
|
||||
secrets.OpenAiNativeApiKey = strPtr(value)
|
||||
case "ollama_api_key":
|
||||
secrets.OllamaApiKey = strPtr(value)
|
||||
case "deep_seek_api_key":
|
||||
secrets.DeepSeekApiKey = strPtr(value)
|
||||
case "requesty_api_key":
|
||||
secrets.RequestyApiKey = strPtr(value)
|
||||
case "together_api_key":
|
||||
secrets.TogetherApiKey = strPtr(value)
|
||||
case "fireworks_api_key":
|
||||
secrets.FireworksApiKey = strPtr(value)
|
||||
case "qwen_api_key":
|
||||
secrets.QwenApiKey = strPtr(value)
|
||||
case "doubao_api_key":
|
||||
secrets.DoubaoApiKey = strPtr(value)
|
||||
case "mistral_api_key":
|
||||
secrets.MistralApiKey = strPtr(value)
|
||||
case "lite_llm_api_key":
|
||||
secrets.LiteLlmApiKey = strPtr(value)
|
||||
case "auth_nonce":
|
||||
secrets.AuthNonce = strPtr(value)
|
||||
case "asksage_api_key":
|
||||
secrets.AsksageApiKey = strPtr(value)
|
||||
case "xai_api_key":
|
||||
secrets.XaiApiKey = strPtr(value)
|
||||
case "moonshot_api_key":
|
||||
secrets.MoonshotApiKey = strPtr(value)
|
||||
case "zai_api_key":
|
||||
secrets.ZaiApiKey = strPtr(value)
|
||||
case "hugging_face_api_key":
|
||||
secrets.HuggingFaceApiKey = strPtr(value)
|
||||
case "nebius_api_key":
|
||||
secrets.NebiusApiKey = strPtr(value)
|
||||
case "sambanova_api_key":
|
||||
secrets.SambanovaApiKey = strPtr(value)
|
||||
case "cerebras_api_key":
|
||||
secrets.CerebrasApiKey = strPtr(value)
|
||||
case "sap_ai_core_client_id":
|
||||
secrets.SapAiCoreClientId = strPtr(value)
|
||||
case "sap_ai_core_client_secret":
|
||||
secrets.SapAiCoreClientSecret = strPtr(value)
|
||||
case "groq_api_key":
|
||||
secrets.GroqApiKey = strPtr(value)
|
||||
case "huawei_cloud_maas_api_key":
|
||||
secrets.HuaweiCloudMaasApiKey = strPtr(value)
|
||||
case "baseten_api_key":
|
||||
secrets.BasetenApiKey = strPtr(value)
|
||||
case "vercel_ai_gateway_api_key":
|
||||
secrets.VercelAiGatewayApiKey = strPtr(value)
|
||||
case "dify_api_key":
|
||||
secrets.DifyApiKey = strPtr(value)
|
||||
case "oca_api_key":
|
||||
secrets.OcaApiKey = strPtr(value)
|
||||
case "oca_refresh_token":
|
||||
secrets.OcaRefreshToken = strPtr(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported secret field '%s'", key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note: message types not supported via -s flags:
|
||||
// - OpenRouterModelInfo, OpenAiCompatibleModelInfo, LiteLLMModelInfo, OcaModelInfo
|
||||
// - LanguageModelChatSelector
|
||||
// - DictationSettings
|
||||
// - FocusChainSettings
|
||||
@@ -1,9 +1,14 @@
|
||||
package task
|
||||
|
||||
import "sync"
|
||||
|
||||
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
|
||||
type StreamCoordinator struct {
|
||||
conversationTurnStartIndex int // First message index of current turn
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
inputAllowed bool // Whether user input is currently allowed
|
||||
mu sync.RWMutex // Protects inputAllowed
|
||||
outputMu sync.Mutex // Protects terminal output (prevents interleaving with input forms)
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
@@ -34,8 +39,43 @@ func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool {
|
||||
return sc.processedInCurrentTurn[key]
|
||||
}
|
||||
|
||||
// CompleteTurn resets the coordinator for the next conversation turn
|
||||
// CompleteTurn updates the start index for the next batch of messages
|
||||
// Note: Does NOT reset the processed map - that persists across state updates
|
||||
func (sc *StreamCoordinator) CompleteTurn(totalMessages int) {
|
||||
sc.conversationTurnStartIndex = totalMessages
|
||||
sc.processedInCurrentTurn = make(map[string]bool)
|
||||
// Don't reset processedInCurrentTurn - it should persist across state updates
|
||||
}
|
||||
|
||||
// SetInputAllowed sets whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) SetInputAllowed(allowed bool) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
sc.inputAllowed = allowed
|
||||
}
|
||||
|
||||
// IsInputAllowed returns whether user input is currently allowed
|
||||
func (sc *StreamCoordinator) IsInputAllowed() bool {
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.inputAllowed
|
||||
}
|
||||
|
||||
// LockOutput locks the output mutex to prevent interleaved terminal output
|
||||
// Should be called before displaying input forms
|
||||
func (sc *StreamCoordinator) LockOutput() {
|
||||
sc.outputMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockOutput unlocks the output mutex
|
||||
// Should be called after input forms are dismissed
|
||||
func (sc *StreamCoordinator) UnlockOutput() {
|
||||
sc.outputMu.Unlock()
|
||||
}
|
||||
|
||||
// WithOutputLock executes a function while holding the output lock
|
||||
// This is a convenience method for wrapping output operations
|
||||
func (sc *StreamCoordinator) WithOutputLock(fn func()) {
|
||||
sc.outputMu.Lock()
|
||||
defer sc.outputMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
if you can make a beautiful tui in go, please help!
|
||||
@@ -3,22 +3,26 @@ package types
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineMessage represents a conversation message in the CLI
|
||||
type ClineMessage struct {
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
Type MessageType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
Say string `json:"say,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
LastCheckpointHash string `json:"lastCheckpointHash,omitempty"`
|
||||
IsCheckpointCheckedOut bool `json:"isCheckpointCheckedOut,omitempty"`
|
||||
IsOperationOutsideWorkspace bool `json:"isOperationOutsideWorkspace,omitempty"`
|
||||
}
|
||||
|
||||
// MessageType represents the type of message
|
||||
@@ -206,13 +210,16 @@ func ConvertProtoToMessage(protoMsg *cline.ClineMessage) *ClineMessage {
|
||||
}
|
||||
|
||||
return &ClineMessage{
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
Type: msgType,
|
||||
Text: protoMsg.Text,
|
||||
Timestamp: protoMsg.Ts,
|
||||
Reasoning: protoMsg.Reasoning,
|
||||
Say: say,
|
||||
Ask: ask,
|
||||
Partial: protoMsg.Partial,
|
||||
LastCheckpointHash: protoMsg.LastCheckpointHash,
|
||||
IsCheckpointCheckedOut: protoMsg.IsCheckpointCheckedOut,
|
||||
IsOperationOutsideWorkspace: protoMsg.IsOperationOutsideWorkspace,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ type ConversationState struct {
|
||||
|
||||
// StreamingMessage manages state for streaming message display
|
||||
type StreamingMessage struct {
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
LastToolMessage string `json:"lastToolMessage,omitempty"`
|
||||
CurrentKey string `json:"currentKey"`
|
||||
LastText string `json:"lastText"`
|
||||
}
|
||||
|
||||
// NewConversationState creates a new conversation state
|
||||
@@ -37,9 +36,8 @@ func (cs *ConversationState) GetStreamingMessage() *StreamingMessage {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return &StreamingMessage{
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
LastToolMessage: cs.StreamingMessage.LastToolMessage,
|
||||
CurrentKey: cs.StreamingMessage.CurrentKey,
|
||||
LastText: cs.StreamingMessage.LastText,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package generated
|
||||
|
||||
// FieldOverrides allows manual control over field relevance per provider
|
||||
// This file is NOT auto-generated and can be edited manually to override
|
||||
// the automatic field filtering logic.
|
||||
//
|
||||
// Usage:
|
||||
// - Add provider-specific overrides to force include/exclude fields
|
||||
// - true = force include this field for this provider
|
||||
// - false = force exclude this field for this provider
|
||||
// - If no override exists, automatic filtering logic applies
|
||||
var FieldOverrides = map[string]map[string]bool{
|
||||
// Format: "provider_id": {"field_name": shouldInclude}
|
||||
|
||||
// Example overrides (uncomment and modify as needed):
|
||||
|
||||
// "anthropic": {
|
||||
// "requestTimeoutMs": true, // explicitly include
|
||||
// "ollamaBaseUrl": false, // explicitly exclude
|
||||
// },
|
||||
|
||||
// "bedrock": {
|
||||
// "awsSessionToken": true, // include even if marked optional
|
||||
// "azureApiVersion": false, // exclude even if general
|
||||
// },
|
||||
|
||||
// Add more provider-specific overrides as needed
|
||||
}
|
||||
|
||||
// GetFieldOverride returns the override setting for a field, if one exists
|
||||
// Returns (shouldInclude, hasOverride)
|
||||
func GetFieldOverride(providerID, fieldName string) (bool, bool) {
|
||||
if providerOverrides, exists := FieldOverrides[providerID]; exists {
|
||||
if override, hasOverride := providerOverrides[fieldName]; hasOverride {
|
||||
return override, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+26
-20
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Global shutdown channel - simple approach
|
||||
@@ -31,11 +34,17 @@ func NewEnvService(verbose bool) *EnvService {
|
||||
// ClipboardWriteText writes text to the system clipboard
|
||||
func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) {
|
||||
if s.verbose {
|
||||
log.Printf("ClipboardWriteText called with: %s", req.GetValue())
|
||||
log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue()))
|
||||
}
|
||||
|
||||
err := clipboard.WriteAll(req.GetValue())
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to write to clipboard: %v", err)
|
||||
}
|
||||
// Don't fail if clipboard is not available (e.g., headless environment)
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, just return success
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
@@ -45,23 +54,17 @@ func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequ
|
||||
log.Printf("ClipboardReadText called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual clipboard functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMachineId returns a stable machine identifier for telemetry distinctId purposes
|
||||
func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetMachineId called")
|
||||
text, err := clipboard.ReadAll()
|
||||
if err != nil {
|
||||
if s.verbose {
|
||||
log.Printf("Failed to read from clipboard: %v", err)
|
||||
}
|
||||
// Return empty string if clipboard is not available
|
||||
text = ""
|
||||
}
|
||||
|
||||
// TODO: Implement actual machine ID functionality
|
||||
// For now, return empty string
|
||||
return &cline.String{
|
||||
Value: "",
|
||||
Value: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -71,9 +74,12 @@ func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest
|
||||
log.Printf("GetHostVersion called")
|
||||
}
|
||||
|
||||
// TODO: Implement actual host version functionality
|
||||
// For now, return empty response
|
||||
return &host.GetHostVersionResponse{}, nil
|
||||
return &host.GetHostVersionResponse{
|
||||
Platform: proto.String("Cline CLI"),
|
||||
Version: proto.String(""),
|
||||
ClineType: proto.String("CLI"),
|
||||
ClineVersion: proto.String(cli.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Shutdown initiates a graceful shutdown of the host bridge service
|
||||
|
||||
@@ -63,3 +63,23 @@ func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.G
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenProblemsPanel opens the problems panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenProblemsPanel(ctx context.Context, req *host.OpenProblemsPanelRequest) (*host.OpenProblemsPanelResponse, error) {
|
||||
return &host.OpenProblemsPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenInFileExplorerPanel opens a file/folder in the file explorer - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenInFileExplorerPanel(ctx context.Context, req *host.OpenInFileExplorerPanelRequest) (*host.OpenInFileExplorerPanelResponse, error) {
|
||||
return &host.OpenInFileExplorerPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenClineSidebarPanel opens the Cline sidebar panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenClineSidebarPanel(ctx context.Context, req *host.OpenClineSidebarPanelRequest) (*host.OpenClineSidebarPanelResponse, error) {
|
||||
return &host.OpenClineSidebarPanelResponse{}, nil
|
||||
}
|
||||
|
||||
// OpenTerminalPanel opens the terminal panel - no-op for console implementation
|
||||
func (s *SimpleWorkspaceService) OpenTerminalPanel(ctx context.Context, req *host.OpenTerminalRequest) (*host.OpenTerminalResponse, error) {
|
||||
return &host.OpenTerminalResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"features/drag-and-drop",
|
||||
"features/editing-messages",
|
||||
"features/focus-chain",
|
||||
"features/multiroot-workspace",
|
||||
"features/plan-and-act",
|
||||
{
|
||||
"group": "Slash Commands",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Multiroot Workspace Support"
|
||||
sidebarTitle: "Multiroot Workspace"
|
||||
---
|
||||
|
||||
Cline's Multiroot feature _(experimental - Oct 1 2025)_ works seamlessly with VSCode's multi-root workspaces, letting you manage multiple project folders in a single workspace.
|
||||
|
||||
## What is Multiroot Workspace Support?
|
||||
|
||||
Instead of being limited to one project folder, Cline can read files, write code, and run commands across all folders in your VSCode workspace. This is helpful when working with monorepos, microservices, or when you're working on related projects simultaneously.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Setting Up Multi-Root Workspaces
|
||||
|
||||
1. **Add folders to your workspace:**
|
||||
- Use `File > Add Folder to Workspace` in VSCode
|
||||
- Or create a `.code-workspace` file with multiple folder paths
|
||||
- Drag and drop folders to the File Explorer
|
||||
- Select multiple folders when opening a new workspace
|
||||
|
||||
2. **Start using Cline** - Cline will automatically detect all your workspace folders and interact with them as needed.
|
||||
|
||||
For detailed instructions on setting up multi-root workspaces in VS Code, see [Microsoft's official guide](https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces).
|
||||
|
||||
### How Cline Handles Multiple Workspaces
|
||||
|
||||
Once you have multiple folders, Cline automatically:
|
||||
|
||||
- Detects all your workspace folders
|
||||
- Works with files across different projects
|
||||
- Executes commands in the right context
|
||||
- Handles path resolution intelligently
|
||||
|
||||
## Working Across Workspaces
|
||||
|
||||
### Let Cline explore, or guide it precisely
|
||||
|
||||
You can reference different workspaces naturally in your prompts:
|
||||
|
||||
```
|
||||
"Read the package.json in my frontend folder and compare it with the backend dependencies"
|
||||
```
|
||||
|
||||
```
|
||||
"Create a shared utility function and update both the client and server to use it"
|
||||
```
|
||||
|
||||
```
|
||||
"Search for TODO comments across all my workspace folders"
|
||||
```
|
||||
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Monorepo Development
|
||||
|
||||
Perfect for when you have related projects in one repository:
|
||||
|
||||
```
|
||||
my-app.code-workspace
|
||||
├── web/ (React frontend)
|
||||
├── api/ (Node.js backend)
|
||||
├── mobile/ (React Native)
|
||||
└── shared/ (Common utilities)
|
||||
```
|
||||
|
||||
Ask Cline: *"Update the API endpoint in both web and mobile apps to match the new backend route"*
|
||||
|
||||
### Microservices Architecture
|
||||
|
||||
Manage multiple services from one workspace:
|
||||
|
||||
```
|
||||
services.code-workspace
|
||||
├── user-service/
|
||||
├── payment-service/
|
||||
├── notifications/
|
||||
└── infrastructure/
|
||||
```
|
||||
|
||||
### Full-Stack Development
|
||||
|
||||
Keep everything together while maintaining separation:
|
||||
|
||||
```
|
||||
fullstack.code-workspace
|
||||
├── client/ (Frontend)
|
||||
├── server/ (Backend API)
|
||||
├── docs/ (Documentation)
|
||||
└── deploy/ (Scripts & config)
|
||||
```
|
||||
|
||||
|
||||
### Auto-Approve Integration
|
||||
|
||||
Multiroot workspaces work with [Auto Approve](/features/auto-approve):
|
||||
|
||||
- Enable permissions for operations within workspace folders
|
||||
- Restrict auto-approve for files outside your workspace(s)
|
||||
- Configure different levels for different workspace folders
|
||||
|
||||
### Cross-Workspace Operations
|
||||
|
||||
Cline can complete tasks spanning multiple workspaces:
|
||||
|
||||
- **Refactoring**: Update imports and references across projects
|
||||
- **Feature development**: Implement features requiring changes in multiple services
|
||||
- **Documentation**: Generate docs referencing code from multiple folders
|
||||
- **Testing**: Build & run tests across all workspaces and analyze results
|
||||
|
||||
When working with large multiroot workspaces, start in [Plan mode](/features/plan-and-act) to let Cline understand your project structure before making changes.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Organizing Your Workspaces
|
||||
|
||||
1. **Group related projects** that often need coordinated changes
|
||||
2. **Use consistent folder structures** across workspaces when possible
|
||||
3. **Name folders clearly** so Cline can understand your project structure
|
||||
|
||||
### Effective Prompting & Tips
|
||||
|
||||
When working with multiroot workspaces, these approaches work best:
|
||||
|
||||
- **Be specific** about which workspace when it matters: *"Update the user model in the backend workspace"*
|
||||
- **Reference relationships**: *"The frontend uses the API types from the shared workspace"*
|
||||
- **Describe cross-workspace operations**: *"This change needs to be reflected in both the web and mobile apps"*
|
||||
- **Scope your searches** when dealing with large codebases: *"Search for 'TODO' in just the frontend workspace"*
|
||||
- **Break down large tasks** into workspace-specific operations when possible
|
||||
- **Consider excluding large folders** like `node_modules` from your workspace search Scope
|
||||
@@ -139,6 +139,10 @@ if (process.env.TELEMETRY_SERVICE_API_KEY) {
|
||||
if (process.env.ERROR_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
|
||||
}
|
||||
|
||||
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
|
||||
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
|
||||
}
|
||||
// Base configuration shared between extension and standalone builds
|
||||
const baseConfig = {
|
||||
bundle: true,
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
@@ -9,15 +10,15 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.32.6",
|
||||
"version": "3.32.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.32.6",
|
||||
"version": "3.32.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -60,6 +60,7 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.8.0",
|
||||
"nice-grpc": "^2.1.12",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"ollama": "^0.5.13",
|
||||
"open": "^10.1.2",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
@@ -12407,6 +12408,12 @@
|
||||
"version": "0.4.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-machine-id": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz",
|
||||
"integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-preload": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
|
||||
|
||||
+8
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.32.6",
|
||||
"version": "3.32.7",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -295,15 +295,20 @@
|
||||
"vscode:prepublish": "npm run package",
|
||||
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
|
||||
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"compile-standalone-cli": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
|
||||
"compile-cli": "scripts/build-cli.sh",
|
||||
"dev:cli": "npm run compile-standalone && npm run compile-cli && ./cli/bin/cline instance kill --all && ./cli/bin/cline instance new",
|
||||
"download-node": "node scripts/download-node.mjs",
|
||||
"test:install": "bash scripts/test-install.sh",
|
||||
"dev:cli:watch": "node scripts/dev-cli-watch.mjs",
|
||||
"postcompile-standalone": "node scripts/package-standalone.mjs",
|
||||
"postcompile-standalone-cli": "node scripts/package-standalone.mjs --target=cli",
|
||||
"watch": "npm-run-all -p watch:*",
|
||||
"watch:esbuild": "node esbuild.mjs --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
|
||||
"protos": "node scripts/build-proto.mjs",
|
||||
"protos-go": "node scripts/build-go-proto.mjs",
|
||||
"cli-providers": "node scripts/cli-providers.mjs",
|
||||
"postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
|
||||
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"clean:deps": "rimraf node_modules webview-ui/node_modules",
|
||||
@@ -444,6 +449,7 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"mammoth": "^1.8.0",
|
||||
"nice-grpc": "^2.1.12",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"ollama": "^0.5.13",
|
||||
"open": "^10.1.2",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
|
||||
@@ -2,7 +2,6 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/state.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
@@ -32,6 +31,11 @@ message ChromePath {
|
||||
bool is_bundled = 2;
|
||||
}
|
||||
|
||||
message Viewport {
|
||||
int32 width = 1;
|
||||
int32 height = 2;
|
||||
}
|
||||
|
||||
message BrowserSettings {
|
||||
Viewport viewport = 1;
|
||||
optional string remote_browser_host = 2;
|
||||
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
@@ -9,6 +10,8 @@ option java_multiple_files = true;
|
||||
service CheckpointsService {
|
||||
rpc checkpointDiff(Int64Request) returns (Empty);
|
||||
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
|
||||
rpc subscribeToCheckpoints(CheckpointSubscriptionRequest) returns (stream CheckpointEvent);
|
||||
rpc getCwdHash(StringArrayRequest) returns (PathHashMap);
|
||||
}
|
||||
|
||||
message CheckpointRestoreRequest {
|
||||
@@ -17,3 +20,26 @@ message CheckpointRestoreRequest {
|
||||
string restore_type = 3;
|
||||
optional int64 offset = 4;
|
||||
}
|
||||
|
||||
message CheckpointSubscriptionRequest {
|
||||
string cwd_hash = 1;
|
||||
}
|
||||
|
||||
message CheckpointEvent {
|
||||
enum OperationType {
|
||||
CHECKPOINT_INIT = 0;
|
||||
CHECKPOINT_COMMIT = 1;
|
||||
CHECKPOINT_RESTORE = 2;
|
||||
}
|
||||
|
||||
OperationType operation = 1;
|
||||
string cwd_hash = 2;
|
||||
bool is_active = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
optional string task_id = 5;
|
||||
optional string commit_hash = 6;
|
||||
}
|
||||
|
||||
message PathHashMap {
|
||||
map<string, string> path_hash = 1;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ message FileSearchRequest {
|
||||
optional string mentions_request_id = 3; // Optional request ID for tracking requests
|
||||
optional int32 limit = 4; // Optional limit for results (default: 20)
|
||||
optional FileSearchType selected_type = 5; // Optional selected type filter
|
||||
optional string workspace_hint = 6; // Optional workspace name to search in
|
||||
}
|
||||
|
||||
// Result for file search operations
|
||||
@@ -120,6 +121,7 @@ message FileInfo {
|
||||
string path = 1; // Relative path from workspace root
|
||||
string type = 2; // "file" or "folder"
|
||||
optional string label = 3; // Display name (usually basename)
|
||||
optional string workspace_name = 4; // Workspace this result came from
|
||||
}
|
||||
|
||||
// Response for searchCommits
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// Input message for all hooks
|
||||
message HookInput {
|
||||
string cline_version = 1;
|
||||
string hook_name = 2;
|
||||
string timestamp = 3;
|
||||
string task_id = 4;
|
||||
repeated string workspace_roots = 5;
|
||||
string user_id = 6;
|
||||
oneof data {
|
||||
PreToolUseData pre_tool_use = 10;
|
||||
PostToolUseData post_tool_use = 11;
|
||||
// Future hooks will be added here
|
||||
// UserPromptSubmitData user_prompt_submit = 12;
|
||||
// TaskStartData task_start = 13;
|
||||
// TaskResumeData task_resume = 14;
|
||||
// TaskCancelData task_cancel = 15;
|
||||
// TaskCompleteData task_complete = 16;
|
||||
// PreCompactData pre_compact = 17;
|
||||
}
|
||||
}
|
||||
|
||||
// Output message for all hooks
|
||||
message HookOutput {
|
||||
string context_modification = 1;
|
||||
bool should_continue = 2;
|
||||
string error_message = 3;
|
||||
}
|
||||
|
||||
// Data for PreToolUse hook
|
||||
message PreToolUseData {
|
||||
string tool_name = 1;
|
||||
map<string, string> parameters = 2;
|
||||
}
|
||||
|
||||
// Data for PostToolUse hook
|
||||
message PostToolUseData {
|
||||
string tool_name = 1;
|
||||
map<string, string> parameters = 2;
|
||||
string result = 3;
|
||||
bool success = 4;
|
||||
int64 execution_time_ms = 5;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "google/protobuf/field_mask.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
@@ -28,6 +29,8 @@ service ModelsService {
|
||||
rpc subscribeToOpenRouterModels(EmptyRequest) returns (stream OpenRouterCompatibleModelInfo);
|
||||
// Updates API configuration
|
||||
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
|
||||
// Updates API configuration with partial values (only updates fields that are explicitly set)
|
||||
rpc updateApiConfigurationPartial(UpdateApiConfigurationPartialRequest) returns (Empty);
|
||||
// Refreshes and returns Groq models
|
||||
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Baseten models
|
||||
@@ -130,6 +133,21 @@ message UpdateApiConfigurationRequest {
|
||||
ModelsApiConfiguration api_configuration = 2;
|
||||
}
|
||||
|
||||
// Request for partially updating API configuration using FieldMask
|
||||
// Only fields specified in update_mask will be updated from api_configuration
|
||||
message UpdateApiConfigurationPartialRequest {
|
||||
Metadata metadata = 1;
|
||||
|
||||
// The API configuration with values to update.
|
||||
// Only fields listed in update_mask will be applied from this configuration.
|
||||
ModelsApiConfiguration api_configuration = 2;
|
||||
|
||||
// Mask specifying which top-level fields from api_configuration to update.
|
||||
// Field names should use camelCase (e.g., "apiKey", "planModeApiProvider").
|
||||
// If a field is in the mask but not set in api_configuration, it will be cleared (set to undefined).
|
||||
google.protobuf.FieldMask update_mask = 3;
|
||||
}
|
||||
|
||||
// Model info for OCA (OpenAI-compatible) models exposed by the OCA provider
|
||||
message OcaModelInfo {
|
||||
// Maximum completion tokens per request supported by this model
|
||||
@@ -325,6 +343,8 @@ message ModelsApiConfiguration {
|
||||
optional string oca_base_url = 73;
|
||||
optional string oca_api_key = 74;
|
||||
optional string oca_refresh_token = 75;
|
||||
optional string oca_mode = 76;
|
||||
optional bool aws_use_global_inference = 77;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
|
||||
+197
-6
@@ -2,6 +2,7 @@ syntax = "proto3";
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/models.proto";
|
||||
import "cline/browser.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
@@ -17,12 +18,198 @@ service StateService {
|
||||
rpc togglePlanActModeProto(TogglePlanActModeRequest) returns (Boolean);
|
||||
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
|
||||
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
|
||||
rpc updateSettingsCli(UpdateSettingsRequestCli) returns (Empty);
|
||||
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
|
||||
rpc updateModelBannerVersion(Int64Request) returns (Empty);
|
||||
rpc getProcessInfo(EmptyRequest) returns (ProcessInfo);
|
||||
}
|
||||
|
||||
message AutoApprovalActions {
|
||||
bool read_files = 1;
|
||||
bool read_files_externally = 2;
|
||||
bool edit_files = 3;
|
||||
bool edit_files_externally = 4;
|
||||
bool execute_safe_commands = 5;
|
||||
bool execute_all_commands = 6;
|
||||
bool use_browser = 7;
|
||||
bool use_mcp = 8;
|
||||
}
|
||||
|
||||
// Auto approval settings for task execution
|
||||
message AutoApprovalSettings {
|
||||
int32 version = 1;
|
||||
bool enabled = 2;
|
||||
AutoApprovalActions actions = 3;
|
||||
int32 max_requests = 4;
|
||||
bool enable_notifications = 5;
|
||||
repeated string favorites = 6;
|
||||
}
|
||||
|
||||
message Secrets {
|
||||
optional string api_key = 1;
|
||||
optional string open_router_api_key = 4;
|
||||
optional string aws_access_key = 5;
|
||||
optional string aws_secret_key = 6;
|
||||
optional string aws_session_token = 7;
|
||||
optional string aws_bedrock_api_key = 8;
|
||||
optional string open_ai_api_key = 9;
|
||||
optional string gemini_api_key = 10;
|
||||
optional string open_ai_native_api_key = 11;
|
||||
optional string ollama_api_key = 12;
|
||||
optional string deep_seek_api_key = 13;
|
||||
optional string requesty_api_key = 14;
|
||||
optional string together_api_key = 15;
|
||||
optional string fireworks_api_key = 16;
|
||||
optional string qwen_api_key = 17;
|
||||
optional string doubao_api_key = 18;
|
||||
optional string mistral_api_key = 19;
|
||||
optional string lite_llm_api_key = 20;
|
||||
optional string auth_nonce = 21;
|
||||
optional string asksage_api_key = 22;
|
||||
optional string xai_api_key = 23;
|
||||
optional string moonshot_api_key = 24;
|
||||
optional string zai_api_key = 25;
|
||||
optional string hugging_face_api_key = 26;
|
||||
optional string nebius_api_key = 27;
|
||||
optional string sambanova_api_key = 28;
|
||||
optional string cerebras_api_key = 29;
|
||||
optional string sap_ai_core_client_id = 30;
|
||||
optional string sap_ai_core_client_secret = 31;
|
||||
optional string groq_api_key = 32;
|
||||
optional string huawei_cloud_maas_api_key = 33;
|
||||
optional string baseten_api_key = 34;
|
||||
optional string vercel_ai_gateway_api_key = 35;
|
||||
optional string dify_api_key = 36;
|
||||
optional string oca_api_key = 37;
|
||||
optional string oca_refresh_token = 38;
|
||||
}
|
||||
|
||||
message Settings {
|
||||
optional string aws_region = 1;
|
||||
optional bool aws_use_cross_region_inference = 2;
|
||||
optional bool aws_bedrock_use_prompt_cache = 3;
|
||||
optional string aws_bedrock_endpoint = 4;
|
||||
optional string aws_profile = 5;
|
||||
optional string aws_authentication = 6;
|
||||
optional bool aws_use_profile = 7;
|
||||
optional string vertex_project_id = 8;
|
||||
optional string vertex_region = 9;
|
||||
optional string requesty_base_url = 10;
|
||||
optional string open_ai_base_url = 11;
|
||||
// map<string, string> open_ai_headers = 12;
|
||||
optional string ollama_base_url = 13;
|
||||
optional string ollama_api_options_ctx_num = 14;
|
||||
optional string lm_studio_base_url = 15;
|
||||
optional string lm_studio_max_tokens = 16;
|
||||
optional string anthropic_base_url = 17;
|
||||
optional string gemini_base_url = 18;
|
||||
optional string azure_api_version = 19;
|
||||
optional string open_router_provider_sorting = 20;
|
||||
optional AutoApprovalSettings auto_approval_settings = 21;
|
||||
optional BrowserSettings browser_settings = 24;
|
||||
optional string lite_llm_base_url = 25;
|
||||
optional bool lite_llm_use_prompt_cache = 26;
|
||||
optional int32 fireworks_model_max_completion_tokens = 27;
|
||||
optional int32 fireworks_model_max_tokens = 28;
|
||||
optional string qwen_api_line = 29;
|
||||
optional string moonshot_api_line = 30;
|
||||
optional string zai_api_line = 31;
|
||||
optional string telemetry_setting = 32;
|
||||
optional string asksage_api_url = 33;
|
||||
optional bool plan_act_separate_models_setting = 34;
|
||||
optional bool enable_checkpoints_setting = 35;
|
||||
optional int32 request_timeout_ms = 36;
|
||||
optional int32 shell_integration_timeout = 37;
|
||||
optional string default_terminal_profile = 38;
|
||||
optional int32 terminal_output_line_limit = 39;
|
||||
optional string sap_ai_core_token_url = 40;
|
||||
optional string sap_ai_core_base_url = 41;
|
||||
optional string sap_ai_resource_group = 42;
|
||||
optional bool sap_ai_core_use_orchestration_mode = 43;
|
||||
optional string claude_code_path = 44;
|
||||
optional string qwen_code_oauth_path = 45;
|
||||
optional bool strict_plan_mode_enabled = 46;
|
||||
optional bool yolo_mode_toggled = 47;
|
||||
optional bool use_auto_condense = 48;
|
||||
optional string preferred_language = 49;
|
||||
optional OpenaiReasoningEffort openai_reasoning_effort = 50;
|
||||
optional PlanActMode mode = 51;
|
||||
optional DictationSettings dictation_settings = 52;
|
||||
optional FocusChainSettings focus_chain_settings = 53;
|
||||
optional string custom_prompt = 54;
|
||||
optional string dify_base_url = 55;
|
||||
optional double auto_condense_threshold = 56;
|
||||
optional string oca_base_url = 57;
|
||||
optional ApiProvider plan_mode_api_provider = 58;
|
||||
optional string plan_mode_api_model_id = 59;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 60;
|
||||
optional string plan_mode_reasoning_effort = 61;
|
||||
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 62;
|
||||
optional bool plan_mode_aws_bedrock_custom_selected = 63;
|
||||
optional string plan_mode_aws_bedrock_custom_model_base_id = 64;
|
||||
optional string plan_mode_open_router_model_id = 65;
|
||||
optional OpenRouterModelInfo plan_mode_open_router_model_info = 66;
|
||||
optional string plan_mode_open_ai_model_id = 67;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 68;
|
||||
optional string plan_mode_ollama_model_id = 69;
|
||||
optional string plan_mode_lm_studio_model_id = 70;
|
||||
optional string plan_mode_lite_llm_model_id = 71;
|
||||
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 72;
|
||||
optional string plan_mode_requesty_model_id = 73;
|
||||
optional OpenRouterModelInfo plan_mode_requesty_model_info = 74;
|
||||
optional string plan_mode_together_model_id = 75;
|
||||
optional string plan_mode_fireworks_model_id = 76;
|
||||
optional string plan_mode_sap_ai_core_model_id = 77;
|
||||
optional string plan_mode_sap_ai_core_deployment_id = 78;
|
||||
optional string plan_mode_groq_model_id = 79;
|
||||
optional OpenRouterModelInfo plan_mode_groq_model_info = 80;
|
||||
optional string plan_mode_baseten_model_id = 81;
|
||||
optional OpenRouterModelInfo plan_mode_baseten_model_info = 82;
|
||||
optional string plan_mode_hugging_face_model_id = 83;
|
||||
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 84;
|
||||
optional string plan_mode_huawei_cloud_maas_model_id = 85;
|
||||
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 86;
|
||||
optional string plan_mode_oca_model_id = 87;
|
||||
optional OcaModelInfo plan_mode_oca_model_info = 88;
|
||||
optional ApiProvider act_mode_api_provider = 89;
|
||||
optional string act_mode_api_model_id = 90;
|
||||
optional int64 act_mode_thinking_budget_tokens = 91;
|
||||
optional string act_mode_reasoning_effort = 92;
|
||||
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 93;
|
||||
optional bool act_mode_aws_bedrock_custom_selected = 94;
|
||||
optional string act_mode_aws_bedrock_custom_model_base_id = 95;
|
||||
optional string act_mode_open_router_model_id = 96;
|
||||
optional OpenRouterModelInfo act_mode_open_router_model_info = 97;
|
||||
optional string act_mode_open_ai_model_id = 98;
|
||||
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 99;
|
||||
optional string act_mode_ollama_model_id = 100;
|
||||
optional string act_mode_lm_studio_model_id = 101;
|
||||
optional string act_mode_lite_llm_model_id = 102;
|
||||
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 103;
|
||||
optional string act_mode_requesty_model_id = 104;
|
||||
optional OpenRouterModelInfo act_mode_requesty_model_info = 105;
|
||||
optional string act_mode_together_model_id = 106;
|
||||
optional string act_mode_fireworks_model_id = 107;
|
||||
optional string act_mode_sap_ai_core_model_id = 108;
|
||||
optional string act_mode_sap_ai_core_deployment_id = 109;
|
||||
optional string act_mode_groq_model_id = 110;
|
||||
optional OpenRouterModelInfo act_mode_groq_model_info = 111;
|
||||
optional string act_mode_baseten_model_id = 112;
|
||||
optional OpenRouterModelInfo act_mode_baseten_model_info = 113;
|
||||
optional string act_mode_hugging_face_model_id = 114;
|
||||
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 115;
|
||||
optional string act_mode_huawei_cloud_maas_model_id = 116;
|
||||
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 117;
|
||||
optional string plan_mode_vercel_ai_gateway_model_id = 118;
|
||||
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 119;
|
||||
optional string act_mode_vercel_ai_gateway_model_id = 120;
|
||||
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 121;
|
||||
optional string act_mode_oca_model_id = 122;
|
||||
optional OcaModelInfo act_mode_oca_model_info = 123;
|
||||
}
|
||||
|
||||
message DictationSettings {
|
||||
bool feature_enabled = 1;
|
||||
bool dictation_enabled = 2;
|
||||
@@ -125,6 +312,12 @@ message BrowserSettingsUpdate {
|
||||
optional string custom_args = 6;
|
||||
}
|
||||
|
||||
message UpdateSettingsRequestCli {
|
||||
Metadata metadata = 1;
|
||||
optional Settings settings = 2;
|
||||
optional Secrets secrets = 3;
|
||||
}
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -149,8 +342,9 @@ message UpdateSettingsRequest {
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional int32 auto_condense_threshold = 24;
|
||||
optional double auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional bool hooks_enabled = 26;
|
||||
}
|
||||
|
||||
// Complete API Configuration message
|
||||
@@ -224,6 +418,8 @@ message ApiConfiguration {
|
||||
optional string oca_base_url = 66;
|
||||
optional string oca_api_key = 67;
|
||||
optional string oca_refresh_token = 68;
|
||||
optional string oca_mode = 69;
|
||||
optional bool aws_use_global_inference = 70;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
@@ -297,11 +493,6 @@ message FocusChainSettings {
|
||||
int32 remind_cline_interval = 2;
|
||||
}
|
||||
|
||||
message Viewport {
|
||||
int32 width = 1;
|
||||
int32 height = 2;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutResponse {
|
||||
optional int32 timeout_ms = 1;
|
||||
}
|
||||
|
||||
+1
-149
@@ -3,33 +3,10 @@ syntax = "proto3";
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
import "cline/state.proto";
|
||||
import "cline/models.proto";
|
||||
import "cline/browser.proto";
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
message AutoApprovalActions {
|
||||
bool read_files = 1;
|
||||
bool read_files_externally = 2;
|
||||
bool edit_files = 3;
|
||||
bool edit_files_externally = 4;
|
||||
bool execute_safe_commands = 5;
|
||||
bool execute_all_commands = 6;
|
||||
bool use_browser = 7;
|
||||
bool use_mcp = 8;
|
||||
}
|
||||
|
||||
// Auto approval settings for task execution
|
||||
message AutoApprovalSettings {
|
||||
int32 version = 1;
|
||||
bool enabled = 2;
|
||||
AutoApprovalActions actions = 3;
|
||||
int32 max_requests = 4;
|
||||
bool enable_notifications = 5;
|
||||
repeated string favorites = 6;
|
||||
}
|
||||
|
||||
service TaskService {
|
||||
// Cancels the currently running task
|
||||
rpc cancelTask(EmptyRequest) returns (Empty);
|
||||
@@ -61,138 +38,13 @@ service TaskService {
|
||||
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
}
|
||||
|
||||
// Task-specific settings
|
||||
message TaskSettings {
|
||||
string aws_region = 1;
|
||||
bool aws_use_cross_region_inference = 2;
|
||||
bool aws_bedrock_use_prompt_cache = 3;
|
||||
string aws_bedrock_endpoint = 4;
|
||||
string aws_profile = 5;
|
||||
string aws_authentication = 6;
|
||||
bool aws_use_profile = 7;
|
||||
string vertex_project_id = 8;
|
||||
string vertex_region = 9;
|
||||
string requesty_base_url = 10;
|
||||
string open_ai_base_url = 11;
|
||||
// map<string, string> open_ai_headers = 12;
|
||||
string ollama_base_url = 13;
|
||||
string ollama_api_options_ctx_num = 14;
|
||||
string lm_studio_base_url = 15;
|
||||
string lm_studio_max_tokens = 16;
|
||||
string anthropic_base_url = 17;
|
||||
string gemini_base_url = 18;
|
||||
string azure_api_version = 19;
|
||||
string open_router_provider_sorting = 20;
|
||||
AutoApprovalSettings auto_approval_settings = 21;
|
||||
BrowserSettings browser_settings = 24;
|
||||
string lite_llm_base_url = 25;
|
||||
bool lite_llm_use_prompt_cache = 26;
|
||||
int32 fireworks_model_max_completion_tokens = 27;
|
||||
int32 fireworks_model_max_tokens = 28;
|
||||
string qwen_api_line = 29;
|
||||
string moonshot_api_line = 30;
|
||||
string zai_api_line = 31;
|
||||
string telemetry_setting = 32;
|
||||
string asksage_api_url = 33;
|
||||
bool plan_act_separate_models_setting = 34;
|
||||
bool enable_checkpoints_setting = 35;
|
||||
int32 request_timeout_ms = 36;
|
||||
int32 shell_integration_timeout = 37;
|
||||
string default_terminal_profile = 38;
|
||||
int32 terminal_output_line_limit = 39;
|
||||
string sap_ai_core_token_url = 40;
|
||||
string sap_ai_core_base_url = 41;
|
||||
string sap_ai_resource_group = 42;
|
||||
bool sap_ai_core_use_orchestration_mode = 43;
|
||||
string claude_code_path = 44;
|
||||
string qwen_code_oauth_path = 45;
|
||||
bool strict_plan_mode_enabled = 46;
|
||||
bool yolo_mode_toggled = 47;
|
||||
bool use_auto_condense = 48;
|
||||
string preferred_language = 49;
|
||||
OpenaiReasoningEffort openai_reasoning_effort = 50;
|
||||
PlanActMode mode = 51;
|
||||
DictationSettings dictation_settings = 52;
|
||||
FocusChainSettings focus_chain_settings = 53;
|
||||
string custom_prompt = 54;
|
||||
string dify_base_url = 55;
|
||||
double auto_condense_threshold = 56;
|
||||
string oca_base_url = 57;
|
||||
ApiProvider plan_mode_api_provider = 58;
|
||||
string plan_mode_api_model_id = 59;
|
||||
int64 plan_mode_thinking_budget_tokens = 60;
|
||||
string plan_mode_reasoning_effort = 61;
|
||||
LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 62;
|
||||
bool plan_mode_aws_bedrock_custom_selected = 63;
|
||||
string plan_mode_aws_bedrock_custom_model_base_id = 64;
|
||||
string plan_mode_open_router_model_id = 65;
|
||||
OpenRouterModelInfo plan_mode_open_router_model_info = 66;
|
||||
string plan_mode_open_ai_model_id = 67;
|
||||
OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 68;
|
||||
string plan_mode_ollama_model_id = 69;
|
||||
string plan_mode_lm_studio_model_id = 70;
|
||||
string plan_mode_lite_llm_model_id = 71;
|
||||
LiteLLMModelInfo plan_mode_lite_llm_model_info = 72;
|
||||
string plan_mode_requesty_model_id = 73;
|
||||
OpenRouterModelInfo plan_mode_requesty_model_info = 74;
|
||||
string plan_mode_together_model_id = 75;
|
||||
string plan_mode_fireworks_model_id = 76;
|
||||
string plan_mode_sap_ai_core_model_id = 77;
|
||||
string plan_mode_sap_ai_core_deployment_id = 78;
|
||||
string plan_mode_groq_model_id = 79;
|
||||
OpenRouterModelInfo plan_mode_groq_model_info = 80;
|
||||
string plan_mode_baseten_model_id = 81;
|
||||
OpenRouterModelInfo plan_mode_baseten_model_info = 82;
|
||||
string plan_mode_hugging_face_model_id = 83;
|
||||
OpenRouterModelInfo plan_mode_hugging_face_model_info = 84;
|
||||
string plan_mode_huawei_cloud_maas_model_id = 85;
|
||||
OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 86;
|
||||
string plan_mode_oca_model_id = 87;
|
||||
OcaModelInfo plan_mode_oca_model_info = 88;
|
||||
ApiProvider act_mode_api_provider = 89;
|
||||
string act_mode_api_model_id = 90;
|
||||
int64 act_mode_thinking_budget_tokens = 91;
|
||||
string act_mode_reasoning_effort = 92;
|
||||
LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 93;
|
||||
bool act_mode_aws_bedrock_custom_selected = 94;
|
||||
string act_mode_aws_bedrock_custom_model_base_id = 95;
|
||||
string act_mode_open_router_model_id = 96;
|
||||
OpenRouterModelInfo act_mode_open_router_model_info = 97;
|
||||
string act_mode_open_ai_model_id = 98;
|
||||
OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 99;
|
||||
string act_mode_ollama_model_id = 100;
|
||||
string act_mode_lm_studio_model_id = 101;
|
||||
string act_mode_lite_llm_model_id = 102;
|
||||
LiteLLMModelInfo act_mode_lite_llm_model_info = 103;
|
||||
string act_mode_requesty_model_id = 104;
|
||||
OpenRouterModelInfo act_mode_requesty_model_info = 105;
|
||||
string act_mode_together_model_id = 106;
|
||||
string act_mode_fireworks_model_id = 107;
|
||||
string act_mode_sap_ai_core_model_id = 108;
|
||||
string act_mode_sap_ai_core_deployment_id = 109;
|
||||
string act_mode_groq_model_id = 110;
|
||||
OpenRouterModelInfo act_mode_groq_model_info = 111;
|
||||
string act_mode_baseten_model_id = 112;
|
||||
OpenRouterModelInfo act_mode_baseten_model_info = 113;
|
||||
string act_mode_hugging_face_model_id = 114;
|
||||
OpenRouterModelInfo act_mode_hugging_face_model_info = 115;
|
||||
string act_mode_huawei_cloud_maas_model_id = 116;
|
||||
OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 117;
|
||||
string plan_mode_vercel_ai_gateway_model_id = 118;
|
||||
OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 119;
|
||||
string act_mode_vercel_ai_gateway_model_id = 120;
|
||||
OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 121;
|
||||
string act_mode_oca_model_id = 122;
|
||||
OcaModelInfo act_mode_oca_model_info = 123;
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
message NewTaskRequest {
|
||||
Metadata metadata = 1;
|
||||
string text = 2;
|
||||
repeated string images = 3;
|
||||
repeated string files = 4;
|
||||
optional TaskSettings task_settings = 5;
|
||||
optional Settings task_settings = 5;
|
||||
}
|
||||
|
||||
// Request message for toggling task favorite status
|
||||
|
||||
@@ -63,6 +63,7 @@ enum ClineSay {
|
||||
LOAD_MCP_DOCUMENTATION = 25;
|
||||
INFO = 26;
|
||||
TASK_PROGRESS = 27;
|
||||
ERROR_RETRY = 28;
|
||||
}
|
||||
|
||||
// Enum for ClineSayTool tool types
|
||||
|
||||
@@ -15,9 +15,6 @@ service EnvService {
|
||||
// Reads text from the system clipboard.
|
||||
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
|
||||
|
||||
// Returns a stable machine identifier for telemetry distinctId purposes.
|
||||
rpc getMachineId(cline.EmptyRequest) returns (cline.String);
|
||||
|
||||
// Returns the name and version of the host IDE or environment.
|
||||
rpc getHostVersion(cline.EmptyRequest) returns (GetHostVersionResponse);
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* API Secrets Parser Module
|
||||
*
|
||||
* Parses the ApiHandlerSecrets TypeScript interface from src/shared/api.ts
|
||||
* to automatically discover API key fields for all providers.
|
||||
*
|
||||
* This eliminates the need for manual maintenance of provider-to-API-key mappings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses the ApiHandlerSecrets interface from api.ts content
|
||||
*
|
||||
* @param {string} content - Content of api.ts file
|
||||
* @returns {Object} Parsed API key fields with metadata
|
||||
* @returns {Object.fields} - Map of field names to their metadata
|
||||
* @returns {Object.fieldNames} - Array of all field names
|
||||
*/
|
||||
export function parseApiHandlerSecrets(content) {
|
||||
// Find the ApiHandlerSecrets interface definition
|
||||
const interfaceMatch = content.match(/export interface ApiHandlerSecrets \{([\s\S]*?)\}/m)
|
||||
|
||||
if (!interfaceMatch) {
|
||||
throw new Error("Could not find ApiHandlerSecrets interface definition")
|
||||
}
|
||||
|
||||
const interfaceContent = interfaceMatch[1]
|
||||
const fields = {}
|
||||
const fieldNames = []
|
||||
|
||||
// Match field definitions like: fieldName?: string // comment
|
||||
const fieldMatches = interfaceContent.matchAll(/^\s*([a-zA-Z][a-zA-Z0-9_]*)\?\s*:\s*([^/\n]+)(?:\/\/\s*(.*))?$/gm)
|
||||
|
||||
for (const match of fieldMatches) {
|
||||
const [, name, type, comment] = match
|
||||
|
||||
fields[name] = {
|
||||
name,
|
||||
type: type.trim(),
|
||||
comment: comment?.trim() || "",
|
||||
isSecret: true, // All fields in ApiHandlerSecrets are secrets
|
||||
}
|
||||
|
||||
fieldNames.push(name)
|
||||
}
|
||||
|
||||
return { fields, fieldNames }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps provider IDs to their required API key fields
|
||||
*
|
||||
* @param {Array<string>} providerIds - List of provider IDs from ApiProvider type
|
||||
* @param {Object} apiSecretsFields - Parsed fields from ApiHandlerSecrets
|
||||
* @returns {Object} Map of provider ID to array of API key field names
|
||||
*
|
||||
* Example output:
|
||||
* {
|
||||
* "anthropic": ["apiKey"],
|
||||
* "bedrock": ["awsAccessKey", "awsSecretKey"],
|
||||
* "cerebras": ["cerebrasApiKey"],
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
export function mapProviderToApiKeys(providerIds, apiSecretsFields) {
|
||||
const providerApiKeyMap = {}
|
||||
|
||||
// Track which fields have been assigned to prevent duplicates
|
||||
const assignedFields = new Set()
|
||||
|
||||
// First pass: Map provider-specific API key fields
|
||||
for (const providerId of providerIds) {
|
||||
const apiKeyFields = []
|
||||
|
||||
for (const fieldName of apiSecretsFields.fieldNames) {
|
||||
if (assignedFields.has(fieldName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const providerFromField = extractProviderFromFieldName(fieldName)
|
||||
|
||||
if (providerFromField === providerId) {
|
||||
apiKeyFields.push(fieldName)
|
||||
assignedFields.add(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKeyFields.length > 0) {
|
||||
providerApiKeyMap[providerId] = apiKeyFields
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Handle special cases and multi-key providers
|
||||
applySpecialCaseMappings(providerApiKeyMap, apiSecretsFields, assignedFields)
|
||||
|
||||
return providerApiKeyMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the provider ID from an API key field name
|
||||
* Uses pattern matching on common naming conventions
|
||||
*
|
||||
* @param {string} fieldName - API key field name (e.g., "cerebrasApiKey")
|
||||
* @returns {string|null} Provider ID or null if not a provider-specific key
|
||||
*/
|
||||
export function extractProviderFromFieldName(fieldName) {
|
||||
// Normalize field name to lowercase for matching
|
||||
const lowerFieldName = fieldName.toLowerCase()
|
||||
|
||||
// SPECIAL CASES FIRST (before pattern matching)
|
||||
|
||||
// Special case: "apiKey" alone maps to "anthropic" (primary provider)
|
||||
if (fieldName === "apiKey") {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
// Special case: clineAccountId maps to "cline"
|
||||
if (lowerFieldName === "clineaccountid") {
|
||||
return "cline"
|
||||
}
|
||||
|
||||
// Special case: authNonce is not provider-specific
|
||||
if (lowerFieldName === "authnonce") {
|
||||
return null
|
||||
}
|
||||
|
||||
// Special case: Vertex fields (not in ApiHandlerSecrets but in ApiHandlerOptions)
|
||||
if (lowerFieldName === "vertexprojectid" || lowerFieldName === "vertexregion") {
|
||||
return "vertex"
|
||||
}
|
||||
|
||||
// Pattern 1: AWS-specific fields (check before generic pattern to avoid false positives)
|
||||
if (lowerFieldName.startsWith("aws")) {
|
||||
// awsAccessKey, awsSecretKey, awsSessionToken, awsRegion -> bedrock
|
||||
if (
|
||||
lowerFieldName.includes("accesskey") ||
|
||||
lowerFieldName.includes("secretkey") ||
|
||||
lowerFieldName.includes("sessiontoken") ||
|
||||
lowerFieldName.includes("region")
|
||||
) {
|
||||
return "bedrock"
|
||||
}
|
||||
// awsBedrockApiKey is explicitly bedrock
|
||||
if (lowerFieldName.includes("bedrock")) {
|
||||
return "bedrock"
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Vertex-specific fields
|
||||
if (lowerFieldName.startsWith("vertex")) {
|
||||
return "vertex"
|
||||
}
|
||||
|
||||
// Pattern 3: SAP AI Core fields
|
||||
if (lowerFieldName.startsWith("sapaicore") || lowerFieldName.startsWith("sapai")) {
|
||||
return "sapaicore"
|
||||
}
|
||||
|
||||
// Pattern 4: Provider name in the middle (e.g., openAiNativeApiKey) - check before generic pattern
|
||||
const providerPatterns = [
|
||||
{ pattern: "openainative", providerId: "openai-native" },
|
||||
{ pattern: "openrouter", providerId: "openrouter" },
|
||||
{ pattern: "openai", providerId: "openai" },
|
||||
{ pattern: "gemini", providerId: "gemini" },
|
||||
{ pattern: "deepseek", providerId: "deepseek" },
|
||||
{ pattern: "ollama", providerId: "ollama" },
|
||||
{ pattern: "lmstudio", providerId: "lmstudio" },
|
||||
{ pattern: "litellm", providerId: "litellm" },
|
||||
{ pattern: "qwen", providerId: "qwen" },
|
||||
{ pattern: "doubao", providerId: "doubao" },
|
||||
{ pattern: "mistral", providerId: "mistral" },
|
||||
{ pattern: "fireworks", providerId: "fireworks" },
|
||||
{ pattern: "asksage", providerId: "asksage" },
|
||||
{ pattern: "xai", providerId: "xai" },
|
||||
{ pattern: "moonshot", providerId: "moonshot" },
|
||||
{ pattern: "sambanova", providerId: "sambanova" },
|
||||
{ pattern: "cerebras", providerId: "cerebras" },
|
||||
{ pattern: "groq", providerId: "groq" },
|
||||
{ pattern: "huggingface", providerId: "huggingface" },
|
||||
{ pattern: "huawei", providerId: "huawei-cloud-maas" },
|
||||
{ pattern: "baseten", providerId: "baseten" },
|
||||
{ pattern: "vercel", providerId: "vercel-ai-gateway" },
|
||||
{ pattern: "zai", providerId: "zai" },
|
||||
{ pattern: "requesty", providerId: "requesty" },
|
||||
{ pattern: "together", providerId: "together" },
|
||||
{ pattern: "dify", providerId: "dify" },
|
||||
]
|
||||
|
||||
for (const { pattern, providerId } of providerPatterns) {
|
||||
if (lowerFieldName.includes(pattern)) {
|
||||
return providerId
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 5: <provider>ApiKey format (most common) - checked LAST to avoid false positives
|
||||
if (lowerFieldName.endsWith("apikey")) {
|
||||
// Extract from ORIGINAL fieldName to preserve camelCase for normalization
|
||||
const providerPart = fieldName.slice(0, -6) // Remove "ApiKey"
|
||||
return normalizeProviderName(providerPart)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes provider name extracted from field name to match provider ID format
|
||||
*
|
||||
* @param {string} providerPart - Provider part extracted from field name
|
||||
* @returns {string} Normalized provider ID
|
||||
*/
|
||||
function normalizeProviderName(providerPart) {
|
||||
// Handle camelCase to kebab-case conversion
|
||||
const normalized = providerPart
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.toLowerCase()
|
||||
.replace(/^-/, "")
|
||||
|
||||
// Handle special cases
|
||||
const specialCases = {
|
||||
"open-router": "openrouter",
|
||||
"open-ai-native": "openai-native",
|
||||
"open-ai": "openai",
|
||||
"lite-llm": "litellm",
|
||||
"deep-seek": "deepseek",
|
||||
"ask-sage": "asksage",
|
||||
"hugging-face": "huggingface",
|
||||
"huawei-cloud-maas": "huawei-cloud-maas",
|
||||
"sap-ai-core": "sapaicore",
|
||||
"vercel-ai-gateway": "vercel-ai-gateway",
|
||||
}
|
||||
|
||||
return specialCases[normalized] || normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies special case mappings for complex provider relationships
|
||||
*
|
||||
* @param {Object} providerApiKeyMap - Current map being built
|
||||
* @param {Object} apiSecretsFields - Parsed API secrets fields
|
||||
* @param {Set<string>} assignedFields - Set of already assigned field names
|
||||
*/
|
||||
function applySpecialCaseMappings(providerApiKeyMap, apiSecretsFields, assignedFields) {
|
||||
// Special case 1: Bedrock needs AWS fields (if not already assigned)
|
||||
const awsFields = ["awsAccessKey", "awsSecretKey", "awsRegion"]
|
||||
const bedrockFields = providerApiKeyMap["bedrock"] || []
|
||||
|
||||
for (const field of awsFields) {
|
||||
if (apiSecretsFields.fieldNames.includes(field) && !bedrockFields.includes(field)) {
|
||||
bedrockFields.push(field)
|
||||
assignedFields.add(field)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: awsSessionToken for temporary credentials
|
||||
if (apiSecretsFields.fieldNames.includes("awsSessionToken") && !bedrockFields.includes("awsSessionToken")) {
|
||||
bedrockFields.push("awsSessionToken")
|
||||
assignedFields.add("awsSessionToken")
|
||||
}
|
||||
|
||||
if (bedrockFields.length > 0) {
|
||||
providerApiKeyMap["bedrock"] = bedrockFields
|
||||
}
|
||||
|
||||
// Special case 2: Vertex needs project ID and region
|
||||
if (providerApiKeyMap["vertex"]) {
|
||||
// Vertex typically uses application default credentials,
|
||||
// but requires project ID and region configuration
|
||||
// These are already captured if they exist in ApiHandlerSecrets
|
||||
}
|
||||
|
||||
// Special case 3: SAP AI Core multi-key authentication
|
||||
if (providerApiKeyMap["sapaicore"]) {
|
||||
const sapFields = providerApiKeyMap["sapaicore"]
|
||||
const requiredSapFields = ["sapAiCoreClientId", "sapAiCoreClientSecret"]
|
||||
|
||||
for (const field of requiredSapFields) {
|
||||
if (apiSecretsFields.fieldNames.includes(field) && !sapFields.includes(field)) {
|
||||
sapFields.push(field)
|
||||
assignedFields.add(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates display name for an API key field
|
||||
* Converts camelCase to Title Case with proper spacing
|
||||
*
|
||||
* @param {string} fieldName - API key field name
|
||||
* @returns {string} Human-readable display name
|
||||
*/
|
||||
export function generateApiKeyDisplayName(fieldName) {
|
||||
// Special cases for known abbreviations
|
||||
const specialCases = {
|
||||
apiKey: "API Key",
|
||||
awsAccessKey: "AWS Access Key",
|
||||
awsSecretKey: "AWS Secret Key",
|
||||
awsSessionToken: "AWS Session Token",
|
||||
awsRegion: "AWS Region",
|
||||
awsBedrockApiKey: "AWS Bedrock API Key",
|
||||
openRouterApiKey: "OpenRouter API Key",
|
||||
openAiApiKey: "OpenAI API Key",
|
||||
openAiNativeApiKey: "OpenAI Native API Key",
|
||||
geminiApiKey: "Gemini API Key",
|
||||
ollamaApiKey: "Ollama API Key",
|
||||
deepSeekApiKey: "DeepSeek API Key",
|
||||
liteLlmApiKey: "LiteLLM API Key",
|
||||
qwenApiKey: "Qwen API Key",
|
||||
doubaoApiKey: "Doubao API Key",
|
||||
mistralApiKey: "Mistral API Key",
|
||||
fireworksApiKey: "Fireworks API Key",
|
||||
asksageApiKey: "AskSage API Key",
|
||||
xaiApiKey: "X AI API Key",
|
||||
moonshotApiKey: "Moonshot API Key",
|
||||
sambanovaApiKey: "SambaNova API Key",
|
||||
cerebrasApiKey: "Cerebras API Key",
|
||||
groqApiKey: "Groq API Key",
|
||||
huggingFaceApiKey: "Hugging Face API Key",
|
||||
nebiusApiKey: "Nebius API Key",
|
||||
basetenApiKey: "Baseten API Key",
|
||||
vercelAiGatewayApiKey: "Vercel AI Gateway API Key",
|
||||
zaiApiKey: "Z AI API Key",
|
||||
requestyApiKey: "Requesty API Key",
|
||||
togetherApiKey: "Together AI API Key",
|
||||
difyApiKey: "Dify API Key",
|
||||
clineAccountId: "Cline Account ID",
|
||||
vertexProjectId: "Vertex Project ID",
|
||||
vertexRegion: "Vertex Region",
|
||||
sapAiCoreClientId: "SAP AI Core Client ID",
|
||||
sapAiCoreClientSecret: "SAP AI Core Client Secret",
|
||||
huaweiCloudMaasApiKey: "Huawei Cloud MaaS API Key",
|
||||
}
|
||||
|
||||
if (specialCases[fieldName]) {
|
||||
return specialCases[fieldName]
|
||||
}
|
||||
|
||||
// Generic conversion: camelCase -> Title Case
|
||||
return fieldName
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all providers have at least one API key field mapped
|
||||
*
|
||||
* @param {Array<string>} providerIds - All provider IDs
|
||||
* @param {Object} providerApiKeyMap - Generated mapping
|
||||
* @returns {Object} Validation result with warnings for unmapped providers
|
||||
*/
|
||||
export function validateApiKeyMappings(providerIds, providerApiKeyMap) {
|
||||
const unmappedProviders = []
|
||||
const warnings = []
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
if (!providerApiKeyMap[providerId] || providerApiKeyMap[providerId].length === 0) {
|
||||
// Some providers don't require API keys - they use alternative authentication:
|
||||
const noKeyProviders = ["vscode-lm", "ollama", "lmstudio", "claude-code", "oca", "vertex", "qwen-code"]
|
||||
|
||||
if (!noKeyProviders.includes(providerId)) {
|
||||
unmappedProviders.push(providerId)
|
||||
warnings.push(`WARNING: Provider "${providerId}" has no API key fields mapped`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: unmappedProviders.length === 0,
|
||||
unmappedProviders,
|
||||
warnings,
|
||||
totalProviders: providerIds.length,
|
||||
mappedProviders: Object.keys(providerApiKeyMap).length,
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ cp package.json dist-standalone/extension
|
||||
|
||||
cd cli
|
||||
GO111MODULE=on go build -o bin/cline ./cmd/cline
|
||||
echo '🖥️ cli/bin/cline built'
|
||||
echo 'cli/bin/cline built'
|
||||
GO111MODULE=on go build -o bin/cline-host ./cmd/cline-host
|
||||
|
||||
echo '🖥️ cli/bin/cline-host built'
|
||||
echo 'cli/bin/cline-host built'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync, spawn } from "child_process"
|
||||
import chokidar from "chokidar"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const projectRoot = path.resolve(__dirname, "..")
|
||||
|
||||
// ANSI color codes
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bright: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
red: "\x1b[31m",
|
||||
cyan: "\x1b[36m",
|
||||
}
|
||||
|
||||
let isBuilding = false
|
||||
let debounceTimer = null
|
||||
let esbuildProcess = null
|
||||
let initialBuildDone = false
|
||||
|
||||
console.log(`${colors.bright}${colors.cyan}🚀 Cline CLI Dev Watch Mode (Fast Incremental)${colors.reset}`)
|
||||
console.log(`${colors.dim}Starting initial build...${colors.reset}\n`)
|
||||
|
||||
// Function to kill all CLI instances
|
||||
function killAllInstances() {
|
||||
try {
|
||||
execSync("./cli/bin/cline instance kill --all", {
|
||||
cwd: projectRoot,
|
||||
stdio: "pipe",
|
||||
})
|
||||
} catch (error) {
|
||||
// Ignore errors - instances might not be running
|
||||
}
|
||||
}
|
||||
|
||||
// Function to start a new CLI instance
|
||||
function startNewInstance() {
|
||||
try {
|
||||
console.log(`${colors.blue}▶️ Starting new CLI instance...${colors.reset}`)
|
||||
const result = execSync("./cli/bin/cline instance new", {
|
||||
cwd: projectRoot,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
})
|
||||
console.log(`${colors.green}✓ CLI instance started${colors.reset}`)
|
||||
console.log(`${colors.dim}${result.trim()}${colors.reset}\n`)
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Failed to start instance: ${error.message}${colors.reset}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Function to rebuild Go CLI
|
||||
async function rebuildGo() {
|
||||
if (isBuilding) {
|
||||
return
|
||||
}
|
||||
|
||||
isBuilding = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
console.log(`${colors.cyan}🔨 Rebuilding Go CLI...${colors.reset}`)
|
||||
killAllInstances()
|
||||
|
||||
// Just rebuild Go binaries (skip proto generation)
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline ./cmd/cline", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline-host ./cmd/cline-host", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
|
||||
startNewInstance()
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
console.log(`${colors.green}✓ Go rebuild complete in ${duration}s${colors.reset}`)
|
||||
console.log(`${colors.dim}Watching for changes...${colors.reset}\n`)
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Go build failed: ${error.message}${colors.reset}\n`)
|
||||
} finally {
|
||||
isBuilding = false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to regenerate protos and rebuild everything
|
||||
async function rebuildProtos() {
|
||||
if (isBuilding) {
|
||||
return
|
||||
}
|
||||
|
||||
isBuilding = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
console.log(`${colors.cyan}🔨 Regenerating protos...${colors.reset}`)
|
||||
killAllInstances()
|
||||
|
||||
// Regenerate protos
|
||||
execSync("npm run protos", { cwd: projectRoot, stdio: "inherit" })
|
||||
execSync("npm run protos-go", { cwd: projectRoot, stdio: "inherit" })
|
||||
|
||||
// esbuild will auto-rebuild TS due to changed generated files
|
||||
// Rebuild Go CLI
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline ./cmd/cline", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline-host ./cmd/cline-host", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
|
||||
startNewInstance()
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
|
||||
console.log(`${colors.green}✓ Proto rebuild complete in ${duration}s${colors.reset}`)
|
||||
console.log(`${colors.dim}Watching for changes...${colors.reset}\n`)
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Proto build failed: ${error.message}${colors.reset}\n`)
|
||||
} finally {
|
||||
isBuilding = false
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced rebuild trigger
|
||||
function triggerGoRebuild(filepath) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
const relativePath = path.relative(projectRoot, filepath)
|
||||
console.log(`${colors.dim}Go file changed: ${relativePath}${colors.reset}`)
|
||||
rebuildGo()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function triggerProtoRebuild(filepath) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
const relativePath = path.relative(projectRoot, filepath)
|
||||
console.log(`${colors.dim}Proto file changed: ${relativePath}${colors.reset}`)
|
||||
rebuildProtos()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Initial build
|
||||
async function initialBuild() {
|
||||
try {
|
||||
// Run protos first
|
||||
console.log(`${colors.blue}📦 Generating protos...${colors.reset}`)
|
||||
execSync("npm run protos", { cwd: projectRoot, stdio: "inherit" })
|
||||
execSync("npm run protos-go", { cwd: projectRoot, stdio: "inherit" })
|
||||
|
||||
// Build standalone (skip check-types and lint for speed)
|
||||
console.log(`${colors.blue}📦 Building standalone...${colors.reset}`)
|
||||
execSync("node esbuild.mjs --standalone", { cwd: projectRoot, stdio: "inherit" })
|
||||
|
||||
// Build Go CLI
|
||||
console.log(`${colors.blue}🔧 Building Go CLI...${colors.reset}`)
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline ./cmd/cline", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
execSync("cd cli && GO111MODULE=on go build -o bin/cline-host ./cmd/cline-host", {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
})
|
||||
|
||||
// Start CLI instance
|
||||
startNewInstance()
|
||||
|
||||
console.log(`${colors.green}${colors.bright}✓ Initial build complete!${colors.reset}`)
|
||||
console.log(`${colors.cyan}Now watching for changes with fast incremental rebuilds...${colors.reset}\n`)
|
||||
|
||||
initialBuildDone = true
|
||||
|
||||
// Start esbuild in watch mode for TypeScript (incremental rebuilds)
|
||||
console.log(`${colors.dim}Starting esbuild watch mode...${colors.reset}`)
|
||||
esbuildProcess = spawn("node", ["esbuild.mjs", "--watch", "--standalone"], {
|
||||
cwd: projectRoot,
|
||||
stdio: ["inherit", "pipe", "inherit"], // Pipe stdout to parse it
|
||||
})
|
||||
|
||||
// Parse esbuild output to detect when rebuild completes
|
||||
esbuildProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
// Forward esbuild output to console
|
||||
process.stdout.write(output)
|
||||
|
||||
// Detect when esbuild finishes a rebuild
|
||||
if (output.includes("[watch] build finished") && initialBuildDone && !isBuilding) {
|
||||
console.log(`${colors.cyan}📦 TypeScript rebuilt by esbuild${colors.reset}`)
|
||||
killAllInstances()
|
||||
startNewInstance()
|
||||
}
|
||||
})
|
||||
|
||||
esbuildProcess.on("error", (error) => {
|
||||
console.error(`${colors.red}esbuild error: ${error.message}${colors.reset}`)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}✗ Initial build failed: ${error.message}${colors.reset}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch Proto files (chokidar v4 - no glob support, watch directory and filter)
|
||||
const protoWatcher = chokidar.watch("proto", {
|
||||
ignored: (filepath, stats) => {
|
||||
// Ignore if it's a file but not a .proto file
|
||||
return stats?.isFile() && !filepath.endsWith(".proto")
|
||||
},
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
cwd: projectRoot,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100,
|
||||
pollInterval: 50,
|
||||
},
|
||||
})
|
||||
|
||||
protoWatcher
|
||||
.on("change", (filepath) => {
|
||||
if (initialBuildDone) {
|
||||
console.log(`${colors.dim}[DEBUG] Proto change event: ${filepath}${colors.reset}`)
|
||||
triggerProtoRebuild(path.join(projectRoot, filepath))
|
||||
}
|
||||
})
|
||||
.on("add", (filepath) => {
|
||||
if (initialBuildDone) {
|
||||
console.log(`${colors.dim}[DEBUG] Proto add event: ${filepath}${colors.reset}`)
|
||||
triggerProtoRebuild(path.join(projectRoot, filepath))
|
||||
}
|
||||
})
|
||||
|
||||
// Watch Go files (chokidar v4 - no glob support, watch directory and filter)
|
||||
const goWatcher = chokidar.watch("cli", {
|
||||
ignored: (filepath, stats) => {
|
||||
// Ignore node_modules and non-.go files
|
||||
if (filepath.includes("node_modules")) return true
|
||||
return stats?.isFile() && !filepath.endsWith(".go")
|
||||
},
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
cwd: projectRoot,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100,
|
||||
pollInterval: 50,
|
||||
},
|
||||
})
|
||||
|
||||
goWatcher
|
||||
.on("change", (filepath) => {
|
||||
if (initialBuildDone) {
|
||||
console.log(`${colors.dim}[DEBUG] Go change event: ${filepath}${colors.reset}`)
|
||||
triggerGoRebuild(path.join(projectRoot, filepath))
|
||||
}
|
||||
})
|
||||
.on("add", (filepath) => {
|
||||
if (initialBuildDone) {
|
||||
console.log(`${colors.dim}[DEBUG] Go add event: ${filepath}${colors.reset}`)
|
||||
triggerGoRebuild(path.join(projectRoot, filepath))
|
||||
}
|
||||
})
|
||||
|
||||
// Handle shutdown gracefully
|
||||
process.on("SIGINT", () => {
|
||||
console.log(`\n${colors.yellow}Shutting down...${colors.reset}`)
|
||||
if (esbuildProcess) {
|
||||
esbuildProcess.kill()
|
||||
}
|
||||
killAllInstances()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log(`\n${colors.yellow}Shutting down...${colors.reset}`)
|
||||
if (esbuildProcess) {
|
||||
esbuildProcess.kill()
|
||||
}
|
||||
killAllInstances()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Start
|
||||
initialBuild()
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Download Node.js binaries for all target platforms
|
||||
* This script downloads official Node.js binaries from nodejs.org
|
||||
* and extracts them to dist-standalone/node-binaries/
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import https from "https"
|
||||
import path from "path"
|
||||
import { pipeline } from "stream/promises"
|
||||
import tar from "tar"
|
||||
import { createGunzip } from "zlib"
|
||||
|
||||
const NODE_VERSION = "22.15.0"
|
||||
const OUTPUT_DIR = "dist-standalone/node-binaries"
|
||||
|
||||
// Platform configurations
|
||||
const PLATFORMS = [
|
||||
{
|
||||
name: "darwin-x64",
|
||||
nodeArch: "darwin-x64",
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-x64.tar.gz`,
|
||||
},
|
||||
{
|
||||
name: "darwin-arm64",
|
||||
nodeArch: "darwin-arm64",
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
|
||||
},
|
||||
{
|
||||
name: "linux-x64",
|
||||
nodeArch: "linux-x64",
|
||||
url: `https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Download a file from a URL
|
||||
*/
|
||||
async function downloadFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(` Downloading: ${url}`)
|
||||
const file = fs.createWriteStream(destPath)
|
||||
|
||||
https
|
||||
.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
return downloadFile(response.headers.location, destPath).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download: ${response.statusCode} ${response.statusMessage}`))
|
||||
return
|
||||
}
|
||||
|
||||
response.pipe(file)
|
||||
|
||||
file.on("finish", () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
.on("error", (err) => {
|
||||
fs.unlink(destPath, () => {}) // Delete the file on error
|
||||
reject(err)
|
||||
})
|
||||
|
||||
file.on("error", (err) => {
|
||||
fs.unlink(destPath, () => {}) // Delete the file on error
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a tar.gz file
|
||||
*/
|
||||
async function extractTarGz(tarPath, destDir) {
|
||||
console.log(` Extracting to: ${destDir}`)
|
||||
|
||||
return pipeline(
|
||||
fs.createReadStream(tarPath),
|
||||
createGunzip(),
|
||||
tar.extract({
|
||||
cwd: destDir,
|
||||
strip: 1, // Remove the top-level directory from the archive
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and extract Node.js for a specific platform
|
||||
*/
|
||||
async function downloadNodeForPlatform(platform) {
|
||||
console.log(`\n📦 Processing ${platform.name}...`)
|
||||
|
||||
const platformDir = path.join(OUTPUT_DIR, platform.name)
|
||||
const tarPath = path.join(OUTPUT_DIR, `node-${platform.name}.tar.gz`)
|
||||
|
||||
// Create output directory
|
||||
fs.mkdirSync(platformDir, { recursive: true })
|
||||
|
||||
try {
|
||||
// Download
|
||||
await downloadFile(platform.url, tarPath)
|
||||
console.log(` ✓ Downloaded`)
|
||||
|
||||
// Extract
|
||||
await extractTarGz(tarPath, platformDir)
|
||||
console.log(` ✓ Extracted`)
|
||||
|
||||
// Verify the binary exists
|
||||
const binaryPath = path.join(platformDir, "bin", "node")
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Binary not found at ${binaryPath}`)
|
||||
}
|
||||
|
||||
// Make binary executable
|
||||
fs.chmodSync(binaryPath, 0o755)
|
||||
console.log(` ✓ Binary ready: ${binaryPath}`)
|
||||
|
||||
// Clean up tar file
|
||||
fs.unlinkSync(tarPath)
|
||||
console.log(` ✓ Cleaned up`)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
console.log("🚀 Node.js Binary Downloader")
|
||||
console.log(` Version: ${NODE_VERSION}`)
|
||||
console.log(` Output: ${OUTPUT_DIR}`)
|
||||
|
||||
// Create output directory
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
|
||||
// Download for all platforms
|
||||
const results = []
|
||||
for (const platform of PLATFORMS) {
|
||||
try {
|
||||
await downloadNodeForPlatform(platform)
|
||||
results.push({ platform: platform.name, success: true })
|
||||
} catch (error) {
|
||||
results.push({ platform: platform.name, success: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log("\n" + "=".repeat(50))
|
||||
console.log("📊 Summary:")
|
||||
console.log("=".repeat(50))
|
||||
|
||||
let successCount = 0
|
||||
for (const result of results) {
|
||||
const status = result.success ? "✅" : "❌"
|
||||
console.log(`${status} ${result.platform}`)
|
||||
if (result.success) {
|
||||
successCount++
|
||||
} else {
|
||||
console.log(` Error: ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("=".repeat(50))
|
||||
console.log(`✓ ${successCount}/${PLATFORMS.length} platforms successful`)
|
||||
|
||||
if (successCount < PLATFORMS.length) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\n✅ All Node.js binaries downloaded successfully!")
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error("\n❌ Fatal error:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+672
@@ -0,0 +1,672 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
ORANGE='\033[38;2;255;140;0m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="${CLINE_INSTALL_DIR:-$HOME/.cline/cli}"
|
||||
GITHUB_REPO="cline/cline"
|
||||
requested_version="${CLINE_VERSION:-}"
|
||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
||||
|
||||
# Detect OS and architecture
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
arch=$(uname -m)
|
||||
|
||||
# Normalize architecture names
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
# Determine platform string
|
||||
case "$os" in
|
||||
darwin)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || {
|
||||
echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported architecture: $arch${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
platform="darwin-$arch"
|
||||
;;
|
||||
linux)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || {
|
||||
echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported architecture: $arch${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
platform="linux-$arch"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported OS: $os${NC}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Print colored message
|
||||
print_message() {
|
||||
local color=$1
|
||||
shift
|
||||
echo -e "${color}$@${NC}"
|
||||
}
|
||||
|
||||
# Print step
|
||||
print_step() {
|
||||
local message=$1
|
||||
echo -e "${CYAN}→${NC} ${DIM}$message${NC}"
|
||||
}
|
||||
|
||||
# Print success
|
||||
print_ok() {
|
||||
local message=$1
|
||||
echo -e "${GREEN}✓${NC} $message"
|
||||
}
|
||||
|
||||
# Print error
|
||||
print_error() {
|
||||
local message=$1
|
||||
echo -e "${RED}✗${NC} ${RED}$message${NC}" >&2
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
check_prerequisites() {
|
||||
print_step "Checking prerequisites"
|
||||
|
||||
for cmd in curl tar; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
print_error "$cmd is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
print_ok "Prerequisites satisfied"
|
||||
}
|
||||
|
||||
|
||||
# Check GitHub API rate limit status and return details
|
||||
check_rate_limit() {
|
||||
local rate_limit_response=$(curl -s "https://api.github.com/rate_limit" 2>/dev/null)
|
||||
|
||||
if [ -z "$rate_limit_response" ]; then
|
||||
return 1 # Can't determine rate limit status
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local remaining=$(echo "$rate_limit_response" | jq -r '.rate.remaining' 2>/dev/null)
|
||||
|
||||
if [ "$remaining" = "0" ]; then
|
||||
return 0 # Rate limited
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1 # Not rate limited
|
||||
}
|
||||
|
||||
# Show detailed rate limit error
|
||||
show_rate_limit_error() {
|
||||
local rate_limit_response=$(curl -s "https://api.github.com/rate_limit" 2>/dev/null)
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local remaining=$(echo "$rate_limit_response" | jq -r '.rate.remaining' 2>/dev/null)
|
||||
local limit=$(echo "$rate_limit_response" | jq -r '.rate.limit' 2>/dev/null)
|
||||
local reset=$(echo "$rate_limit_response" | jq -r '.rate.reset' 2>/dev/null)
|
||||
local used=$(echo "$rate_limit_response" | jq -r '.rate.used' 2>/dev/null)
|
||||
|
||||
print_error "GitHub API rate limit exceeded"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Rate Limit Status:${NC}"
|
||||
echo -e " ${CYAN}Used:${NC} ${BOLD}$used${NC} / $limit requests"
|
||||
echo -e " ${CYAN}Remaining:${NC} ${RED}${BOLD}$remaining${NC}"
|
||||
echo -e " ${CYAN}Resets at:${NC} ${BOLD}$(date -r $reset 2>/dev/null || date -d @$reset 2>/dev/null)${NC}"
|
||||
echo ""
|
||||
|
||||
# Calculate time until reset
|
||||
local now=$(date +%s)
|
||||
local seconds_until_reset=$((reset - now))
|
||||
local minutes_until_reset=$((seconds_until_reset / 60))
|
||||
|
||||
if [ $seconds_until_reset -gt 0 ]; then
|
||||
echo -e "${YELLOW}Your rate limit will reset in ${BOLD}~$minutes_until_reset minutes${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}Options:${NC}"
|
||||
echo -e " ${DIM}1.${NC} Wait for the rate limit to reset"
|
||||
echo -e " ${DIM}2.${NC} Use a GitHub Personal Access Token for 5,000 requests/hour:"
|
||||
echo ""
|
||||
echo -e " ${GREEN}GITHUB_TOKEN=your_token bash scripts/install.sh${NC}"
|
||||
echo ""
|
||||
echo -e " ${DIM}Create a token at: https://github.com/settings/tokens${NC}"
|
||||
echo ""
|
||||
else
|
||||
print_error "GitHub API rate limit exceeded"
|
||||
echo ""
|
||||
echo -e "${YELLOW}You've used all 60 requests. Please wait ~1 hour or use a GitHub token.${NC}"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Get download URL and version
|
||||
get_release_info() {
|
||||
if [ -z "$requested_version" ]; then
|
||||
print_step "Fetching latest CLI release"
|
||||
|
||||
# Build auth header if token provided
|
||||
local auth_header=""
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
auth_header="-H \"Authorization: Bearer $GITHUB_TOKEN\""
|
||||
fi
|
||||
|
||||
# Use jq if available for more reliable parsing
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local response=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases" 2>&1)
|
||||
local curl_exit=$?
|
||||
|
||||
# If curl failed, diagnose why
|
||||
if [ $curl_exit -ne 0 ]; then
|
||||
# Check if it's a rate limit issue
|
||||
if check_rate_limit; then
|
||||
show_rate_limit_error
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if it's a network issue
|
||||
if ! curl -s --connect-timeout 5 "https://api.github.com" >/dev/null 2>&1; then
|
||||
print_error "Could not connect to GitHub"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Please check your internet connection and try again.${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generic error
|
||||
print_error "Failed to fetch releases from GitHub"
|
||||
echo ""
|
||||
echo -e "${DIM}Error: $response${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if response is valid JSON
|
||||
if ! echo "$response" | jq empty 2>/dev/null; then
|
||||
print_error "Invalid response from GitHub API"
|
||||
echo ""
|
||||
echo -e "${DIM}Response preview:${NC}"
|
||||
echo "$response" | head -5
|
||||
echo ""
|
||||
|
||||
# Double-check rate limit
|
||||
if check_rate_limit; then
|
||||
show_rate_limit_error
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse release info
|
||||
local release_info=$(echo "$response" | \
|
||||
jq -r '.[] | select(.tag_name | endswith("-cli")) | .tag_name + "|" + (.assets[] | select(.name | contains("'"$platform"'") and endswith(".tar.gz")) | .browser_download_url) | select(length > 0)' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$release_info" ]; then
|
||||
# No matching release found - show what's available
|
||||
local latest_cli_tag=$(echo "$response" | jq -r '.[] | select(.tag_name | endswith("-cli")) | .tag_name' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$latest_cli_tag" ]; then
|
||||
print_error "No CLI releases found"
|
||||
echo ""
|
||||
echo -e "${DIM}Visit: https://github.com/$GITHUB_REPO/releases${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_error "No release found for platform: $platform"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Latest CLI release: ${BOLD}$latest_cli_tag${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}Available platforms:${NC}"
|
||||
echo "$response" | jq -r '.[] | select(.tag_name | endswith("-cli")) | .assets[].name' 2>/dev/null | grep "\.tar\.gz$" | head -5 | sed 's/^/ /'
|
||||
echo ""
|
||||
echo -e "${DIM}Visit: https://github.com/$GITHUB_REPO/releases/tag/$latest_cli_tag${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cli_tag=$(echo "$release_info" | cut -d'|' -f1)
|
||||
download_url=$(echo "$release_info" | cut -d'|' -f2)
|
||||
else
|
||||
# Fallback: fetch specific release by tag (similar error handling)
|
||||
local releases_data=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases" 2>&1)
|
||||
local curl_exit=$?
|
||||
|
||||
if [ $curl_exit -ne 0 ]; then
|
||||
if check_rate_limit; then
|
||||
show_rate_limit_error
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_error "Failed to fetch releases from GitHub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the first tag ending in -cli
|
||||
cli_tag=$(echo "$releases_data" | grep -o '"tag_name": "[^"]*-cli"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$cli_tag" ]; then
|
||||
print_error "No CLI releases found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fetch the specific release to get assets
|
||||
local release_data=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$cli_tag")
|
||||
|
||||
# Extract download URL from the specific release
|
||||
download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${platform}[^\"]*\.tar\.gz\"" | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
print_ok "Found version ${MAGENTA}${BOLD}$cli_tag${NC}"
|
||||
else
|
||||
# Similar logic for specific version...
|
||||
print_step "Fetching version ${MAGENTA}$requested_version${NC}"
|
||||
cli_tag="$requested_version"
|
||||
|
||||
local auth_header=""
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
auth_header="-H \"Authorization: Bearer $GITHUB_TOKEN\""
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
download_url=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$requested_version" | \
|
||||
jq -r '.assets[] | select(.name | contains("'"$platform"'") and endswith(".tar.gz")) | .browser_download_url' | head -1)
|
||||
else
|
||||
local release_data=$(eval curl -fsSL $auth_header "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$requested_version")
|
||||
download_url=$(echo "$release_data" | grep -o "\"browser_download_url\": \"[^\"]*${platform}[^\"]*\.tar\.gz\"" | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$download_url" ]; then
|
||||
print_error "Could not find $platform package in release $cli_tag"
|
||||
echo -e "${DIM}Visit: https://github.com/$GITHUB_REPO/releases/tag/$cli_tag${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if already installed with same version
|
||||
check_existing_installation() {
|
||||
# Skip check if force install
|
||||
if [ "$FORCE_INSTALL" = "true" ]; then
|
||||
print_message "$YELLOW" "Force reinstalling..."
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -d "$INSTALL_DIR/bin" ] && [ -f "$INSTALL_DIR/bin/cline" ]; then
|
||||
# Extract version from cline binary
|
||||
local installed_version=$("$INSTALL_DIR/bin/cline" version 2>/dev/null | head -1 | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' || echo "")
|
||||
|
||||
# Compare versions (remove -cli suffix for comparison)
|
||||
local cli_tag_version=$(echo "$cli_tag" | sed 's/-cli$//')
|
||||
|
||||
if [ -n "$installed_version" ] && [ "$installed_version" = "$cli_tag_version" ]; then
|
||||
echo ""
|
||||
print_ok "Cline ${MAGENTA}${BOLD}$installed_version${NC} already installed"
|
||||
echo ""
|
||||
print_message "$DIM" "Installation directory: $INSTALL_DIR"
|
||||
print_message "$DIM" "To reinstall, run: ${MAGENTA}rm -rf $INSTALL_DIR && <install command>${NC}"
|
||||
print_message "$DIM" "Or use: ${MAGENTA}FORCE_INSTALL=true${NC} to force reinstall"
|
||||
echo ""
|
||||
exit 0
|
||||
elif [ -n "$installed_version" ]; then
|
||||
print_message "$YELLOW" "Upgrading from ${MAGENTA}$installed_version${YELLOW} to ${MAGENTA}${BOLD}$cli_tag_version${NC}"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Download and install
|
||||
install_cline() {
|
||||
print_step "Installing Cline"
|
||||
|
||||
# Create temporary directory
|
||||
local tmp_dir=$(mktemp -d)
|
||||
trap "rm -rf $tmp_dir" EXIT
|
||||
|
||||
# Download with progress bar
|
||||
echo -e "${MAGENTA}${BOLD}"
|
||||
local package_file="$tmp_dir/cline.tar.gz"
|
||||
|
||||
if ! curl -#fSL -o "$package_file" "$download_url"; then
|
||||
echo -e "${NC}"
|
||||
print_error "Failed to download package"
|
||||
echo -e "${DIM}URL: $download_url${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${NC}"
|
||||
|
||||
# Verify download
|
||||
if [ ! -f "$package_file" ]; then
|
||||
print_error "Download failed: file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local file_size=$(stat -f%z "$package_file" 2>/dev/null || stat -c%s "$package_file" 2>/dev/null)
|
||||
print_ok "Downloaded $(numfmt --to=iec $file_size 2>/dev/null || echo "$file_size bytes")"
|
||||
|
||||
# Remove existing installation
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Create installation directory
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Extract package
|
||||
print_step "Extracting package"
|
||||
if ! tar -xzf "$package_file" -C "$INSTALL_DIR" --strip-components=0; then
|
||||
print_error "Failed to extract package"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make binaries executable
|
||||
if [ -d "$INSTALL_DIR/bin" ]; then
|
||||
chmod +x "$INSTALL_DIR/bin/"* 2>/dev/null || true
|
||||
else
|
||||
print_error "No bin directory found"
|
||||
echo -e "${DIM}Contents of $INSTALL_DIR:${NC}"
|
||||
ls -la "$INSTALL_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy platform-specific native modules
|
||||
if [ -d "$INSTALL_DIR/binaries/$platform/node_modules" ]; then
|
||||
if cp -r "$INSTALL_DIR/binaries/$platform/node_modules/"* "$INSTALL_DIR/node_modules/" 2>/dev/null; then
|
||||
print_ok "Native modules installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
print_ok "Cline installed to ${MAGENTA}${BOLD}$INSTALL_DIR${NC}"
|
||||
}
|
||||
|
||||
# Configure PATH
|
||||
configure_path() {
|
||||
local bin_dir="$INSTALL_DIR/bin"
|
||||
local XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
|
||||
print_step "Configuring PATH"
|
||||
|
||||
# Detect shell and config files
|
||||
local current_shell=$(basename "${SHELL:-bash}")
|
||||
local config_files=""
|
||||
|
||||
case $current_shell in
|
||||
fish)
|
||||
config_files="$HOME/.config/fish/config.fish"
|
||||
;;
|
||||
zsh)
|
||||
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc"
|
||||
;;
|
||||
bash)
|
||||
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc"
|
||||
;;
|
||||
ash|sh)
|
||||
config_files="$HOME/.profile /etc/profile"
|
||||
;;
|
||||
*)
|
||||
config_files="$HOME/.profile"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Find first existing config file
|
||||
local config_file=""
|
||||
for file in $config_files; do
|
||||
if [ -f "$file" ]; then
|
||||
config_file="$file"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Create default if none exists
|
||||
if [ -z "$config_file" ]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
config_file="$HOME/.config/fish/config.fish"
|
||||
mkdir -p "$(dirname "$config_file")"
|
||||
;;
|
||||
zsh)
|
||||
config_file="$HOME/.zshrc"
|
||||
;;
|
||||
*)
|
||||
config_file="$HOME/.bashrc"
|
||||
;;
|
||||
esac
|
||||
touch "$config_file"
|
||||
fi
|
||||
|
||||
# Add to config if not already present
|
||||
if ! grep -q "$bin_dir" "$config_file" 2>/dev/null; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
echo -e "\n# Cline CLI\nfish_add_path $bin_dir" >> "$config_file"
|
||||
;;
|
||||
*)
|
||||
echo -e "\n# Cline CLI\nexport PATH=\"$bin_dir:\$PATH\"" >> "$config_file"
|
||||
;;
|
||||
esac
|
||||
|
||||
print_ok "Added to PATH in ${CYAN}$(basename $config_file)${NC}"
|
||||
else
|
||||
print_ok "Already in PATH"
|
||||
fi
|
||||
|
||||
# Add to GitHub Actions PATH if applicable
|
||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
echo "$bin_dir" >> "$GITHUB_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
verify_installation() {
|
||||
print_step "Verifying installation"
|
||||
|
||||
local cline_bin="$INSTALL_DIR/bin/cline"
|
||||
|
||||
if [ ! -f "$cline_bin" ]; then
|
||||
print_error "Binary not found at $cline_bin"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -x "$cline_bin" ]; then
|
||||
chmod +x "$cline_bin"
|
||||
fi
|
||||
|
||||
print_ok "Installation verified"
|
||||
}
|
||||
|
||||
|
||||
# Smart centered box printer
|
||||
# Smart centered box printer
|
||||
print_box() {
|
||||
local text=$1
|
||||
local color=$2
|
||||
local preferred_width=${3:-48} # Default preferred box width of 48
|
||||
|
||||
# Try multiple methods to get terminal width
|
||||
local term_width_tput=$(tput cols 2>/dev/null || echo 0)
|
||||
local term_width_stty=$(stty size 2>/dev/null | cut -d' ' -f2 || echo 0)
|
||||
local term_width_env=${COLUMNS:-0}
|
||||
|
||||
# Build array of valid widths
|
||||
local widths=()
|
||||
[ "$term_width_tput" -gt 0 ] 2>/dev/null && widths+=($term_width_tput)
|
||||
[ "$term_width_stty" -gt 0 ] 2>/dev/null && widths+=($term_width_stty)
|
||||
[ "$term_width_env" -gt 0 ] 2>/dev/null && widths+=($term_width_env)
|
||||
|
||||
# Smart selection logic
|
||||
local term_width=80 # fallback
|
||||
if [ ${#widths[@]} -gt 0 ]; then
|
||||
# Find min and max
|
||||
local min_width=${widths[0]}
|
||||
local max_width=${widths[0]}
|
||||
for width in "${widths[@]}"; do
|
||||
if [ "$width" -lt "$min_width" ]; then
|
||||
min_width=$width
|
||||
fi
|
||||
if [ "$width" -gt "$max_width" ]; then
|
||||
max_width=$width
|
||||
fi
|
||||
done
|
||||
|
||||
# If any width is less than preferred (48), use the smallest (most conservative)
|
||||
# Otherwise, use the largest (give more space)
|
||||
if [ "$min_width" -lt "$preferred_width" ]; then
|
||||
term_width=$min_width
|
||||
else
|
||||
term_width=$max_width
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure we have a valid number and reasonable minimum
|
||||
if ! [[ "$term_width" =~ ^[0-9]+$ ]] || [ "$term_width" -lt 20 ]; then
|
||||
term_width=80
|
||||
fi
|
||||
|
||||
# Calculate content width (length of longest line in text)
|
||||
local content_width=0
|
||||
while IFS= read -r line; do
|
||||
# Strip ANSI color codes for accurate length measurement
|
||||
local clean_line=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
local line_length=${#clean_line}
|
||||
if [ $line_length -gt $content_width ]; then
|
||||
content_width=$line_length
|
||||
fi
|
||||
done <<< "$text"
|
||||
|
||||
# Calculate max possible box width (terminal width - 4 chars total padding minimum)
|
||||
local max_box_width=$((term_width - 4))
|
||||
|
||||
# Determine internal text padding based on box width
|
||||
# For narrow boxes (< 30), use 1 space padding; otherwise 2 spaces
|
||||
local text_padding_size=2
|
||||
if [ $max_box_width -lt 30 ]; then
|
||||
text_padding_size=1
|
||||
fi
|
||||
|
||||
# Start with preferred width, but respect terminal constraints
|
||||
local box_width=$preferred_width
|
||||
if [ $box_width -gt $max_box_width ]; then
|
||||
box_width=$max_box_width
|
||||
fi
|
||||
|
||||
# Ensure box is at least wide enough for content (with adaptive padding)
|
||||
local min_width=$((content_width + (text_padding_size * 2) + 2)) # content + padding + borders
|
||||
if [ $box_width -lt $min_width ]; then
|
||||
box_width=$min_width
|
||||
# If even min_width exceeds terminal, shrink to fit
|
||||
if [ $box_width -gt $max_box_width ]; then
|
||||
box_width=$max_box_width
|
||||
fi
|
||||
fi
|
||||
|
||||
# Absolute minimum box width
|
||||
if [ $box_width -lt 10 ]; then
|
||||
box_width=10
|
||||
fi
|
||||
|
||||
# Calculate horizontal padding to center the box in terminal
|
||||
local box_padding=$(( (term_width - box_width) / 2 ))
|
||||
if [ $box_padding -lt 1 ]; then
|
||||
box_padding=1 # Ensure at least 1 character padding
|
||||
fi
|
||||
|
||||
# Build horizontal line
|
||||
local horizontal_line="═"
|
||||
for ((i=1; i<box_width-2; i++)); do
|
||||
horizontal_line+="═"
|
||||
done
|
||||
|
||||
# Decide whether to include empty lines based on box height constraints
|
||||
local include_empty_lines=true
|
||||
if [ $box_width -lt 25 ]; then
|
||||
include_empty_lines=false # Skip empty lines for very narrow boxes
|
||||
fi
|
||||
|
||||
# Print top border
|
||||
printf "%${box_padding}s" ""
|
||||
echo -e "${color}╔${horizontal_line}╗${NC}"
|
||||
|
||||
# Print empty line (optional)
|
||||
if [ "$include_empty_lines" = true ]; then
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%$((box_width-2))s" ""
|
||||
printf "║${NC}\n"
|
||||
fi
|
||||
|
||||
# Print content lines (centered)
|
||||
while IFS= read -r line; do
|
||||
# Strip ANSI codes for length calculation
|
||||
local clean_line=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
local line_length=${#clean_line}
|
||||
|
||||
# Calculate padding with adaptive spacing
|
||||
local left_text_padding=$(( (box_width - 2 - line_length) / 2 ))
|
||||
local right_text_padding=$((box_width - 2 - line_length - left_text_padding))
|
||||
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%${left_text_padding}s" ""
|
||||
printf "%s" "$line"
|
||||
printf "%${right_text_padding}s" ""
|
||||
printf "║${NC}\n"
|
||||
done <<< "$text"
|
||||
|
||||
# Print empty line (optional)
|
||||
if [ "$include_empty_lines" = true ]; then
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%$((box_width-2))s" ""
|
||||
printf "║${NC}\n"
|
||||
fi
|
||||
|
||||
# Print bottom border
|
||||
printf "%${box_padding}s" ""
|
||||
echo -e "${color}╚${horizontal_line}╝${NC}"
|
||||
}
|
||||
|
||||
# Print success message
|
||||
print_success() {
|
||||
echo ""
|
||||
print_box "Installation complete" "$GREEN$BOLD" 48
|
||||
echo ""
|
||||
print_message "$NC" "Run this to start using ${MAGENTA}${BOLD}cline${NC} immediately:"
|
||||
echo ""
|
||||
print_message "$YELLOW" "${BOLD} exec \$SHELL"
|
||||
echo ""
|
||||
print_message "$DIM" "(or just open a new terminal window)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main installation flow
|
||||
main() {
|
||||
echo ""
|
||||
print_box "CLINE IS COOKING" "$MAGENTA$BOLD" 48
|
||||
echo ""
|
||||
print_ok "Platform: ${MAGENTA}${BOLD}$platform${NC}"
|
||||
check_prerequisites
|
||||
get_release_info
|
||||
check_existing_installation
|
||||
install_cline
|
||||
configure_path
|
||||
verify_installation
|
||||
print_success
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -13,6 +13,8 @@ import { rmrf } from "./file-utils.mjs"
|
||||
const BUILD_DIR = "dist-standalone"
|
||||
const BINARIES_DIR = `${BUILD_DIR}/binaries`
|
||||
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
|
||||
const NODE_BINARIES_DIR = `${BUILD_DIR}/node-binaries`
|
||||
const CLI_BINARIES_DIR = "cli/bin"
|
||||
const IS_DEBUG_BUILD = process.env.IS_DEBUG_BUILD === "true"
|
||||
|
||||
// This should match the node version packaged with the JetBrains plugin.
|
||||
@@ -28,15 +30,56 @@ const SUPPORTED_BINARY_MODULES = ["better-sqlite3"]
|
||||
const UNIVERSAL_BUILD = !process.argv.includes("-s")
|
||||
const IS_VERBOSE = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
|
||||
// Parse --target flag (e.g., --target=cli)
|
||||
// Default behavior is JetBrains build (no binaries)
|
||||
// Use --target=cli for standalone CLI build (with binaries)
|
||||
const targetArg = process.argv.find((arg) => arg.startsWith("--target="))
|
||||
const BUILD_TARGET = targetArg ? targetArg.split("=")[1] : "jetbrains"
|
||||
const IS_CLI_BUILD = BUILD_TARGET === "cli"
|
||||
|
||||
// Detect current platform
|
||||
function getCurrentPlatform() {
|
||||
const platform = os.platform()
|
||||
const arch = os.arch()
|
||||
|
||||
if (platform === "darwin") {
|
||||
return arch === "arm64" ? "darwin-arm64" : "darwin-x64"
|
||||
} else if (platform === "linux") {
|
||||
return "linux-x64"
|
||||
} else if (platform === "win32") {
|
||||
return "win-x64"
|
||||
}
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`🚀 Building Cline ${IS_CLI_BUILD ? "Standalone CLI" : "JetBrains"} Package\n`)
|
||||
|
||||
// Step 1: Install Node.js dependencies
|
||||
await installNodeDependencies()
|
||||
|
||||
// Step 2: Copy Node.js binary (only for CLI builds)
|
||||
// Step 3: Copy CLI binaries (only for CLI builds)
|
||||
// Step 4: Create VERSION file (only for CLI builds)
|
||||
if (IS_CLI_BUILD) {
|
||||
await copyNodeBinary()
|
||||
await copyCliBinaries()
|
||||
await createVersionFile()
|
||||
}
|
||||
|
||||
// Step 6: Package platform-specific binary modules
|
||||
if (UNIVERSAL_BUILD) {
|
||||
console.log("Building universal package for all platforms...")
|
||||
console.log("\nBuilding universal package for all platforms...")
|
||||
await packageAllBinaryDeps()
|
||||
} else {
|
||||
console.log(`Building package for ${os.platform()}-${os.arch()}...`)
|
||||
console.log(`\nBuilding package for ${os.platform()}-${os.arch()}...`)
|
||||
}
|
||||
|
||||
// Step 7: Create final package
|
||||
console.log("\n📦 Creating final package...")
|
||||
await zipDistribution()
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
}
|
||||
|
||||
async function installNodeDependencies() {
|
||||
@@ -54,6 +97,94 @@ async function installNodeDependencies() {
|
||||
fs.renameSync(`${BUILD_DIR}/vscode`, `${BUILD_DIR}/node_modules/vscode`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy Node.js binary for the current platform
|
||||
*/
|
||||
async function copyNodeBinary() {
|
||||
const currentPlatform = getCurrentPlatform()
|
||||
const nodeBinarySource = path.join(NODE_BINARIES_DIR, currentPlatform, "bin", "node")
|
||||
const nodeBinaryDest = path.join(BUILD_DIR, "bin", "node")
|
||||
|
||||
console.log(`Copying Node.js binary for ${currentPlatform}...`)
|
||||
|
||||
// Check if Node.js binaries exist
|
||||
if (!fs.existsSync(nodeBinarySource)) {
|
||||
console.error(`Error: Node.js binary not found at ${nodeBinarySource}`)
|
||||
console.error(`Please run: npm run download-node`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create bin directory
|
||||
fs.mkdirSync(path.join(BUILD_DIR, "bin"), { recursive: true })
|
||||
|
||||
// Copy Node.js binary
|
||||
await cpr(nodeBinarySource, nodeBinaryDest)
|
||||
|
||||
// Make it executable
|
||||
fs.chmodSync(nodeBinaryDest, 0o755)
|
||||
|
||||
console.log(`✓ Node.js binary copied to ${nodeBinaryDest}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy CLI binaries (cline and cline-host)
|
||||
* The Go binary is named 'cline' and includes service management
|
||||
*/
|
||||
async function copyCliBinaries() {
|
||||
console.log("Copying CLI binaries...")
|
||||
|
||||
const binaries = [
|
||||
{ source: "cline", dest: "cline" },
|
||||
{ source: "cline-host", dest: "cline-host" },
|
||||
]
|
||||
const binDir = path.join(BUILD_DIR, "bin")
|
||||
|
||||
// Create bin directory
|
||||
fs.mkdirSync(binDir, { recursive: true })
|
||||
|
||||
for (const { source, dest } of binaries) {
|
||||
const sourcePath = path.join(CLI_BINARIES_DIR, source)
|
||||
const destPath = path.join(binDir, dest)
|
||||
|
||||
// Check if binary exists
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
console.error(`Error: CLI binary not found at ${sourcePath}`)
|
||||
console.error(`Please run: npm run compile-cli`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Copy binary
|
||||
await cpr(sourcePath, destPath)
|
||||
|
||||
// Make it executable
|
||||
fs.chmodSync(destPath, 0o755)
|
||||
|
||||
console.log(`✓ ${source} copied to ${destPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a VERSION file with build metadata
|
||||
*/
|
||||
async function createVersionFile() {
|
||||
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
const version = packageJson.version
|
||||
const platform = getCurrentPlatform()
|
||||
const buildDate = new Date().toISOString()
|
||||
|
||||
const versionInfo = {
|
||||
version,
|
||||
platform,
|
||||
buildDate,
|
||||
nodeVersion: TARGET_NODE_VERSION,
|
||||
}
|
||||
|
||||
const versionPath = path.join(BUILD_DIR, "VERSION.txt")
|
||||
fs.writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2))
|
||||
|
||||
console.log(`✓ VERSION file created: ${version} (${platform})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads prebuilt binaries for each platform for the modules that include binaries. It uses `npx prebuild-install`
|
||||
* to download the binary.
|
||||
@@ -106,8 +237,10 @@ async function packageAllBinaryDeps() {
|
||||
}
|
||||
|
||||
async function zipDistribution() {
|
||||
// Zip the build directory (excluding any pre-existing output zip).
|
||||
const zipPath = path.join(BUILD_DIR, "standalone.zip")
|
||||
// Use different filename for CLI builds
|
||||
// Default (JetBrains) = standalone.zip, CLI = standalone-cli.zip
|
||||
const zipFilename = IS_CLI_BUILD ? "standalone-cli.zip" : "standalone.zip"
|
||||
const zipPath = path.join(BUILD_DIR, zipFilename)
|
||||
const output = fs.createWriteStream(zipPath)
|
||||
const startTime = Date.now()
|
||||
const archive = archiver("zip", { zlib: { level: 6 } })
|
||||
@@ -125,15 +258,33 @@ async function zipDistribution() {
|
||||
})
|
||||
|
||||
archive.pipe(output)
|
||||
|
||||
// Build ignore lists for build directory and extension directory
|
||||
const ignorePatterns = ["standalone.zip", "standalone-cli.zip"]
|
||||
const extensionIgnores = ["dist/**"]
|
||||
|
||||
// For JetBrains (default) builds, exclude binaries from both directories
|
||||
if (!IS_CLI_BUILD) {
|
||||
// JetBrains provides their own Node.js, so exclude all binaries
|
||||
ignorePatterns.push(
|
||||
"bin/**", // Exclude entire bin directory
|
||||
"node-binaries/**", // Exclude all platform-specific Node.js binaries
|
||||
)
|
||||
extensionIgnores.push(
|
||||
"cli/bin/**", // Exclude CLI binaries from extension
|
||||
"node-binaries/**", // Exclude node-binaries from extension
|
||||
)
|
||||
console.log("JetBrains build: Excluding Node.js and CLI binaries (JetBrains provides its own Node.js)")
|
||||
}
|
||||
|
||||
// Add all the files from the standalone build dir.
|
||||
archive.glob("**/*", {
|
||||
cwd: BUILD_DIR,
|
||||
ignore: ["standalone.zip"],
|
||||
ignore: ignorePatterns,
|
||||
})
|
||||
|
||||
// Exclude the same files as the VCE vscode extension packager.
|
||||
// Also ignore the dist directory, the build directory for the extension.
|
||||
const isIgnored = createIsIgnored(["dist/**"])
|
||||
const isIgnored = createIsIgnored(extensionIgnores)
|
||||
|
||||
// Add the whole cline directory under "extension", except the for the ignored files.
|
||||
archive.directory(process.cwd(), "extension", (entry) => {
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Test script for install.sh
|
||||
# This validates the install script without actually running it
|
||||
|
||||
set -e
|
||||
|
||||
echo "Testing install.sh script..."
|
||||
echo ""
|
||||
|
||||
# Test 1: Script syntax
|
||||
echo "Test 1: Script Syntax Verification"
|
||||
if bash -n scripts/install.sh; then
|
||||
echo " ✅ PASS: Script syntax is valid"
|
||||
else
|
||||
echo " ❌ FAIL: Script has syntax errors"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 2: Check for required functions
|
||||
echo "Test 2: Required Functions Check"
|
||||
required_functions=(
|
||||
"detect_platform"
|
||||
"print_message"
|
||||
"error_exit"
|
||||
"command_exists"
|
||||
"check_prerequisites"
|
||||
"get_download_url"
|
||||
"install_cline"
|
||||
"configure_path"
|
||||
"verify_installation"
|
||||
"print_success"
|
||||
"main"
|
||||
)
|
||||
|
||||
for func in "${required_functions[@]}"; do
|
||||
if grep -q "^$func()" scripts/install.sh || grep -q "^${func} ()" scripts/install.sh; then
|
||||
echo " ✅ PASS: Function '$func' exists"
|
||||
else
|
||||
echo " ❌ FAIL: Function '$func' not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Test 3: Check for required variables
|
||||
echo "Test 3: Required Variables Check"
|
||||
required_vars=(
|
||||
"INSTALL_DIR"
|
||||
"GITHUB_REPO"
|
||||
"RELEASE_TAG"
|
||||
)
|
||||
|
||||
for var in "${required_vars[@]}"; do
|
||||
if grep -q "$var=" scripts/install.sh; then
|
||||
echo " ✅ PASS: Variable '$var' is defined"
|
||||
else
|
||||
echo " ❌ FAIL: Variable '$var' not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Test 4: Check for platform support
|
||||
echo "Test 4: Platform Support Check"
|
||||
platforms=("darwin-x64" "darwin-arm64" "linux-x64")
|
||||
for platform in "${platforms[@]}"; do
|
||||
if grep -q "$platform" scripts/install.sh; then
|
||||
echo " ✅ PASS: Platform '$platform' supported"
|
||||
else
|
||||
echo " ❌ FAIL: Platform '$platform' not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Test 5: Check for error handling
|
||||
echo "Test 5: Error Handling Check"
|
||||
if grep -q "error_exit" scripts/install.sh && grep -q "set -e" scripts/install.sh; then
|
||||
echo " ✅ PASS: Error handling present"
|
||||
else
|
||||
echo " ❌ FAIL: Error handling missing"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 6: Check for PATH configuration
|
||||
echo "Test 6: PATH Configuration Check"
|
||||
if grep -q "export PATH=" scripts/install.sh; then
|
||||
echo " ✅ PASS: PATH configuration present"
|
||||
else
|
||||
echo " ❌ FAIL: PATH configuration missing"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 7: Check for verification step
|
||||
echo "Test 7: Installation Verification Check"
|
||||
if grep -q "verify_installation" scripts/install.sh; then
|
||||
echo " ✅ PASS: Installation verification present"
|
||||
else
|
||||
echo " ❌ FAIL: Installation verification missing"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "All tests passed! ✅"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "The install script is ready for use:"
|
||||
echo " curl -fsSL https://raw.githubusercontent.com/cline/cline/main/scripts/install.sh | bash"
|
||||
Executable
+308
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
ORANGE='\033[38;2;255;140;0m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
INSTALL_DIR="${CLINE_INSTALL_DIR:-$HOME/.cline/cli}"
|
||||
|
||||
# Print colored message
|
||||
print_message() {
|
||||
local color=$1
|
||||
shift
|
||||
echo -e "${color}$@${NC}"
|
||||
}
|
||||
|
||||
# Print step
|
||||
print_step() {
|
||||
local message=$1
|
||||
echo -e "${CYAN}→${NC} ${DIM}$message${NC}"
|
||||
}
|
||||
|
||||
# Print success
|
||||
print_ok() {
|
||||
local message=$1
|
||||
echo -e "${GREEN}✓${NC} $message"
|
||||
}
|
||||
|
||||
# Print warning
|
||||
print_warning() {
|
||||
local message=$1
|
||||
echo -e "${YELLOW}⚠${NC} ${YELLOW}$message${NC}"
|
||||
}
|
||||
|
||||
# Print error
|
||||
print_error() {
|
||||
local message=$1
|
||||
echo -e "${RED}✗${NC} ${RED}$message${NC}" >&2
|
||||
}
|
||||
|
||||
# Smart centered box printer
|
||||
print_box() {
|
||||
local text=$1
|
||||
local color=$2
|
||||
local preferred_width=${3:-48}
|
||||
|
||||
# Try multiple methods to get terminal width
|
||||
local term_width_tput=$(tput cols 2>/dev/null || echo 0)
|
||||
local term_width_stty=$(stty size 2>/dev/null | cut -d' ' -f2 || echo 0)
|
||||
local term_width_env=${COLUMNS:-0}
|
||||
|
||||
# Build array of valid widths
|
||||
local widths=()
|
||||
[ "$term_width_tput" -gt 0 ] 2>/dev/null && widths+=($term_width_tput)
|
||||
[ "$term_width_stty" -gt 0 ] 2>/dev/null && widths+=($term_width_stty)
|
||||
[ "$term_width_env" -gt 0 ] 2>/dev/null && widths+=($term_width_env)
|
||||
|
||||
# Smart selection logic
|
||||
local term_width=80
|
||||
if [ ${#widths[@]} -gt 0 ]; then
|
||||
local min_width=${widths[0]}
|
||||
local max_width=${widths[0]}
|
||||
for width in "${widths[@]}"; do
|
||||
if [ "$width" -lt "$min_width" ]; then
|
||||
min_width=$width
|
||||
fi
|
||||
if [ "$width" -gt "$max_width" ]; then
|
||||
max_width=$width
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$min_width" -lt "$preferred_width" ]; then
|
||||
term_width=$min_width
|
||||
else
|
||||
term_width=$max_width
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! [[ "$term_width" =~ ^[0-9]+$ ]] || [ "$term_width" -lt 20 ]; then
|
||||
term_width=80
|
||||
fi
|
||||
|
||||
# Calculate content width
|
||||
local content_width=0
|
||||
while IFS= read -r line; do
|
||||
local clean_line=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
local line_length=${#clean_line}
|
||||
if [ $line_length -gt $content_width ]; then
|
||||
content_width=$line_length
|
||||
fi
|
||||
done <<< "$text"
|
||||
|
||||
local max_box_width=$((term_width - 4))
|
||||
local text_padding_size=2
|
||||
if [ $max_box_width -lt 30 ]; then
|
||||
text_padding_size=1
|
||||
fi
|
||||
|
||||
local box_width=$preferred_width
|
||||
if [ $box_width -gt $max_box_width ]; then
|
||||
box_width=$max_box_width
|
||||
fi
|
||||
|
||||
local min_width=$((content_width + (text_padding_size * 2) + 2))
|
||||
if [ $box_width -lt $min_width ]; then
|
||||
box_width=$min_width
|
||||
if [ $box_width -gt $max_box_width ]; then
|
||||
box_width=$max_box_width
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $box_width -lt 10 ]; then
|
||||
box_width=10
|
||||
fi
|
||||
|
||||
local box_padding=$(( (term_width - box_width) / 2 ))
|
||||
if [ $box_padding -lt 1 ]; then
|
||||
box_padding=1
|
||||
fi
|
||||
|
||||
# Build horizontal line
|
||||
local horizontal_line="═"
|
||||
for ((i=1; i<box_width-2; i++)); do
|
||||
horizontal_line+="═"
|
||||
done
|
||||
|
||||
local include_empty_lines=true
|
||||
if [ $box_width -lt 25 ]; then
|
||||
include_empty_lines=false
|
||||
fi
|
||||
|
||||
# Print top border
|
||||
printf "%${box_padding}s" ""
|
||||
echo -e "${color}╔${horizontal_line}╗${NC}"
|
||||
|
||||
if [ "$include_empty_lines" = true ]; then
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%$((box_width-2))s" ""
|
||||
printf "║${NC}\n"
|
||||
fi
|
||||
|
||||
# Print content lines
|
||||
while IFS= read -r line; do
|
||||
local clean_line=$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g')
|
||||
local line_length=${#clean_line}
|
||||
|
||||
local left_text_padding=$(( (box_width - 2 - line_length) / 2 ))
|
||||
local right_text_padding=$((box_width - 2 - line_length - left_text_padding))
|
||||
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%${left_text_padding}s" ""
|
||||
printf "%s" "$line"
|
||||
printf "%${right_text_padding}s" ""
|
||||
printf "║${NC}\n"
|
||||
done <<< "$text"
|
||||
|
||||
if [ "$include_empty_lines" = true ]; then
|
||||
printf "%${box_padding}s" ""
|
||||
printf "${color}║"
|
||||
printf "%$((box_width-2))s" ""
|
||||
printf "║${NC}\n"
|
||||
fi
|
||||
|
||||
# Print bottom border
|
||||
printf "%${box_padding}s" ""
|
||||
echo -e "${color}╚${horizontal_line}╝${NC}"
|
||||
}
|
||||
|
||||
# Check if Cline is installed
|
||||
check_installation() {
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
print_error "Cline CLI is not installed at $INSTALL_DIR"
|
||||
echo ""
|
||||
print_message "$DIM" "If Cline is installed elsewhere, set CLINE_INSTALL_DIR:"
|
||||
print_message "$DIM" " ${CYAN}CLINE_INSTALL_DIR=/path/to/cline bash scripts/uninstall-cli.sh${NC}"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if cline binary exists
|
||||
if [ -f "$INSTALL_DIR/bin/cline" ]; then
|
||||
local installed_version=$("$INSTALL_DIR/bin/cline" version 2>/dev/null | head -1 | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' || echo "unknown")
|
||||
print_ok "Found Cline ${MAGENTA}${BOLD}$installed_version${NC} at ${CYAN}$INSTALL_DIR${NC}"
|
||||
else
|
||||
print_warning "Installation directory exists but binary not found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove installation directory
|
||||
remove_installation() {
|
||||
print_step "Removing installation directory"
|
||||
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
rm -rf "$INSTALL_DIR"
|
||||
print_ok "Removed ${CYAN}$INSTALL_DIR${NC}"
|
||||
else
|
||||
print_message "$DIM" "Installation directory already removed"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remove PATH configuration
|
||||
remove_from_path() {
|
||||
local bin_dir="$INSTALL_DIR/bin"
|
||||
local XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
|
||||
print_step "Cleaning up PATH configuration"
|
||||
|
||||
# Detect shell and config files
|
||||
local current_shell=$(basename "${SHELL:-bash}")
|
||||
local config_files=""
|
||||
|
||||
case $current_shell in
|
||||
fish)
|
||||
config_files="$HOME/.config/fish/config.fish"
|
||||
;;
|
||||
zsh)
|
||||
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc"
|
||||
;;
|
||||
bash)
|
||||
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc"
|
||||
;;
|
||||
ash|sh)
|
||||
config_files="$HOME/.profile /etc/profile"
|
||||
;;
|
||||
*)
|
||||
config_files="$HOME/.profile"
|
||||
;;
|
||||
esac
|
||||
|
||||
local removed=false
|
||||
|
||||
# Remove from each config file
|
||||
for config_file in $config_files; do
|
||||
if [ -f "$config_file" ]; then
|
||||
# Check if our PATH entry exists
|
||||
if grep -q "$bin_dir" "$config_file" 2>/dev/null; then
|
||||
# Create backup
|
||||
cp "$config_file" "$config_file.cline-backup"
|
||||
|
||||
# Remove lines containing the bin_dir
|
||||
case $current_shell in
|
||||
fish)
|
||||
# Remove fish_add_path line and preceding comment
|
||||
sed -i.tmp '/# Cline CLI/d; /fish_add_path.*cline\/cli\/bin/d' "$config_file"
|
||||
;;
|
||||
*)
|
||||
# Remove export PATH line and preceding comment
|
||||
sed -i.tmp '/# Cline CLI/d; /export PATH=.*cline\/cli\/bin/d' "$config_file"
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -f "$config_file.tmp"
|
||||
print_ok "Removed from ${CYAN}$(basename $config_file)${NC} (backup: ${CYAN}$(basename $config_file).cline-backup${NC})"
|
||||
removed=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$removed" = false ]; then
|
||||
print_message "$DIM" "No PATH entries found"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Print final message
|
||||
print_completion() {
|
||||
echo ""
|
||||
print_box "Uninstall complete" "$GREEN$BOLD" 48
|
||||
echo ""
|
||||
print_message "$NC" "Cline CLI has been removed from your system."
|
||||
echo ""
|
||||
print_message "$DIM" "To finish cleanup, run:"
|
||||
echo ""
|
||||
print_message "$YELLOW" "${BOLD} exec \$SHELL"
|
||||
echo ""
|
||||
print_message "$DIM" "(or just open a new terminal window)"
|
||||
echo ""
|
||||
print_message "$DIM" "To reinstall Cline, visit:"
|
||||
print_message "$DIM" " ${CYAN}https://github.com/cline/cline/releases${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main uninstall flow
|
||||
main() {
|
||||
echo ""
|
||||
print_box "CLINE UNINSTALLER" "$MAGENTA$BOLD" 48
|
||||
echo ""
|
||||
|
||||
check_installation
|
||||
echo ""
|
||||
remove_installation
|
||||
remove_from_path
|
||||
print_completion
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
cleanupMcpMarketplaceCatalogFromGlobalState,
|
||||
migrateCustomInstructionsToGlobalRules,
|
||||
migrateTaskHistoryToFile,
|
||||
migrateWelcomeViewCompleted,
|
||||
@@ -17,8 +18,8 @@ import { audioRecordingService } from "./services/dictation/AudioRecordingServic
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { initializeDistinctId } from "./services/logging/distinctId"
|
||||
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
|
||||
import { telemetryService } from "./services/telemetry"
|
||||
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
/**
|
||||
@@ -60,6 +61,9 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Ensure taskHistory.json exists and migrate legacy state (runs once)
|
||||
await migrateTaskHistoryToFile(context)
|
||||
|
||||
// Clean up MCP marketplace catalog from global state (moved to disk cache)
|
||||
await cleanupMcpMarketplaceCatalogFromGlobalState(context)
|
||||
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
await FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ function createHandlerForProvider(
|
||||
awsAuthentication: options.awsAuthentication,
|
||||
awsBedrockApiKey: options.awsBedrockApiKey,
|
||||
awsUseCrossRegionInference: options.awsUseCrossRegionInference,
|
||||
awsUseGlobalInference: options.awsUseGlobalInference,
|
||||
awsBedrockUsePromptCache: options.awsBedrockUsePromptCache,
|
||||
awsUseProfile: options.awsUseProfile,
|
||||
awsProfile: options.awsProfile,
|
||||
@@ -376,6 +377,7 @@ function createHandlerForProvider(
|
||||
})
|
||||
case "oca":
|
||||
return new OcaHandler({
|
||||
ocaMode: options.ocaMode || "internal",
|
||||
ocaBaseUrl: options.ocaBaseUrl,
|
||||
ocaModelId: mode === "plan" ? options.planModeOcaModelId : options.actModeOcaModelId,
|
||||
ocaModelInfo: mode === "plan" ? options.planModeOcaModelInfo : options.actModeOcaModelInfo,
|
||||
|
||||
@@ -213,6 +213,7 @@ describe("AwsBedrockHandler", () => {
|
||||
awsBedrockApiKey: "",
|
||||
awsBedrockUsePromptCache: false,
|
||||
awsUseCrossRegionInference: false,
|
||||
awsUseGlobalInference: false,
|
||||
awsBedrockEndpoint: "",
|
||||
awsBedrockCustomSelected: false,
|
||||
awsBedrockCustomModelBaseId: undefined,
|
||||
@@ -612,102 +613,141 @@ describe("AwsBedrockHandler", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Re-enable or remove these tests.
|
||||
// describe("getModelId", () => {
|
||||
// it("should return raw model ID for custom models", async () => {
|
||||
// const customOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId:
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// }
|
||||
// const customHandler = new AwsBedrockHandler(customOptions)
|
||||
describe("getModelId", () => {
|
||||
it("should return raw model ID for custom models", async () => {
|
||||
const customOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsBedrockCustomSelected: true,
|
||||
apiModelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
}
|
||||
const customHandler = new AwsBedrockHandler(customOptions)
|
||||
|
||||
// const modelId = await customHandler.getModelId()
|
||||
// modelId.should.equal(
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// )
|
||||
// })
|
||||
const modelId = await customHandler.getModelId()
|
||||
modelId.should.equal(
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
)
|
||||
})
|
||||
|
||||
// it("should not encode custom model IDs with slashes", async () => {
|
||||
// const customOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId: "my-namespace/my-custom-model",
|
||||
// }
|
||||
// const customHandler = new AwsBedrockHandler(customOptions)
|
||||
it("should not encode custom model IDs with slashes", async () => {
|
||||
const customOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsBedrockCustomSelected: true,
|
||||
apiModelId: "my-namespace/my-custom-model",
|
||||
}
|
||||
const customHandler = new AwsBedrockHandler(customOptions)
|
||||
|
||||
// const modelId = await customHandler.getModelId()
|
||||
// modelId.should.equal("my-namespace/my-custom-model")
|
||||
// modelId.should.not.match(/%2F/)
|
||||
// })
|
||||
const modelId = await customHandler.getModelId()
|
||||
modelId.should.equal("my-namespace/my-custom-model")
|
||||
modelId.should.not.match(/%2F/)
|
||||
})
|
||||
|
||||
// it("should apply cross-region prefix for non-custom models when enabled", async () => {
|
||||
// const crossRegionOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "us-west-2",
|
||||
// }
|
||||
// const crossRegionHandler = new AwsBedrockHandler(crossRegionOptions)
|
||||
it("should apply cross-region prefix for non-custom models when enabled", async () => {
|
||||
const crossRegionOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "us-west-2",
|
||||
}
|
||||
const crossRegionHandler = new AwsBedrockHandler(crossRegionOptions)
|
||||
|
||||
// const modelId = await crossRegionHandler.getModelId()
|
||||
// modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
const modelId = await crossRegionHandler.getModelId()
|
||||
modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
|
||||
// it("should apply EU cross-region prefix", async () => {
|
||||
// const euOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "eu-central-1",
|
||||
// }
|
||||
// const euHandler = new AwsBedrockHandler(euOptions)
|
||||
it("should apply EU cross-region prefix", async () => {
|
||||
const euOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "eu-central-1",
|
||||
}
|
||||
const euHandler = new AwsBedrockHandler(euOptions)
|
||||
|
||||
// const modelId = await euHandler.getModelId()
|
||||
// modelId.should.equal("eu.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
const modelId = await euHandler.getModelId()
|
||||
modelId.should.equal("eu.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
|
||||
// it("should apply APAC cross-region prefix", async () => {
|
||||
// const apacOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// awsUseCrossRegionInference: true,
|
||||
// awsRegion: "ap-northeast-1",
|
||||
// }
|
||||
// const apacHandler = new AwsBedrockHandler(apacOptions)
|
||||
it("should apply JP cross-region prefix for sonnet 4.5", async () => {
|
||||
const jpOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
apiModelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const jpHandler = new AwsBedrockHandler(jpOptions)
|
||||
|
||||
// const modelId = await apacHandler.getModelId()
|
||||
// modelId.should.equal("apac.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
// })
|
||||
const modelId = await jpHandler.getModelId()
|
||||
modelId.should.equal("jp.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
})
|
||||
|
||||
// it("should not apply cross-region prefix for custom models even when enabled", async () => {
|
||||
// const customCrossRegionOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
|
||||
// awsUseCrossRegionInference: true,
|
||||
// }
|
||||
// const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
|
||||
it("should apply global cross-region prefix for supported models", async () => {
|
||||
const globalOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsUseGlobalInference: true,
|
||||
apiModelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const globalHandler = new AwsBedrockHandler(globalOptions)
|
||||
|
||||
// const modelId = await customCrossRegionHandler.getModelId()
|
||||
// modelId.should.equal("arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model")
|
||||
// })
|
||||
const modelId = await globalHandler.getModelId()
|
||||
modelId.should.equal("global.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
})
|
||||
|
||||
// it("should handle UltraThink model ARN correctly", async () => {
|
||||
// const ultraThinkOptions: ApiHandlerOptions = {
|
||||
// ...mockOptions,
|
||||
// actModeAwsBedrockCustomSelected: true,
|
||||
// actModeApiModelId:
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// actModeAwsBedrockCustomModelBaseId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
// }
|
||||
// const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
|
||||
it("should NOT apply global cross-region prefix for unsupported models", async () => {
|
||||
const options: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsUseGlobalInference: true,
|
||||
apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", // 3.7 does not support a global inference profile
|
||||
awsRegion: "us-west-2",
|
||||
}
|
||||
const usHandler = new AwsBedrockHandler(options)
|
||||
|
||||
// const modelId = await ultraThinkHandler.getModelId()
|
||||
// // Should return the raw ARN without any encoding
|
||||
// modelId.should.equal(
|
||||
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
// )
|
||||
// modelId.should.not.match(/%2F/)
|
||||
// modelId.should.not.match(/%3A/)
|
||||
// })
|
||||
// })
|
||||
const modelId = await usHandler.getModelId()
|
||||
modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
|
||||
it("should apply APAC cross-region prefix", async () => {
|
||||
const apacOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsUseCrossRegionInference: true,
|
||||
awsRegion: "ap-northeast-1",
|
||||
}
|
||||
const apacHandler = new AwsBedrockHandler(apacOptions)
|
||||
|
||||
const modelId = await apacHandler.getModelId()
|
||||
modelId.should.equal("apac.anthropic.claude-3-7-sonnet-20250219-v1:0")
|
||||
})
|
||||
|
||||
it("should not apply cross-region prefix for custom models even when enabled", async () => {
|
||||
const customCrossRegionOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsBedrockCustomSelected: true,
|
||||
apiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
|
||||
awsUseCrossRegionInference: true,
|
||||
}
|
||||
const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
|
||||
|
||||
const modelId = await customCrossRegionHandler.getModelId()
|
||||
modelId.should.equal("arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model")
|
||||
})
|
||||
|
||||
it("should handle UltraThink model ARN correctly", async () => {
|
||||
const ultraThinkOptions: AwsBedrockHandlerOptions = {
|
||||
...mockOptions,
|
||||
awsBedrockCustomSelected: true,
|
||||
apiModelId:
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
}
|
||||
const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
|
||||
|
||||
const modelId = await ultraThinkHandler.getModelId()
|
||||
// Should return the raw ARN without any encoding
|
||||
modelId.should.equal(
|
||||
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
|
||||
)
|
||||
modelId.should.not.match(/%2F/)
|
||||
modelId.should.not.match(/%3A/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AwsBedrockHandlerOptions extends CommonApiHandlerOptions {
|
||||
awsAuthentication?: string
|
||||
awsBedrockApiKey?: string
|
||||
awsUseCrossRegionInference?: boolean
|
||||
awsUseGlobalInference?: boolean
|
||||
awsBedrockUsePromptCache?: boolean
|
||||
awsUseProfile?: boolean
|
||||
awsProfile?: string
|
||||
@@ -106,6 +107,10 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for sonnet 4.5
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = ["anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0:1m"]
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
private options: AwsBedrockHandlerOptions
|
||||
@@ -271,6 +276,9 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (!this.options.awsBedrockCustomSelected && this.options.awsUseCrossRegionInference) {
|
||||
if (this.getModel().info.supportsGlobalEndpoint && this.options.awsUseGlobalInference) {
|
||||
return `global.${this.getModel().id}`
|
||||
}
|
||||
const regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
@@ -278,6 +286,9 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
case "eu-":
|
||||
return `eu.${this.getModel().id}`
|
||||
case "ap-":
|
||||
if (JP_SUPPORTED_CRIS_MODELS.includes(this.getModel().id)) {
|
||||
return `jp.${this.getModel().id}`
|
||||
}
|
||||
return `apac.${this.getModel().id}`
|
||||
default:
|
||||
// cross region inference is not supported in this region, falling back to default model
|
||||
|
||||
@@ -3,7 +3,11 @@ import { LiteLLMModelInfo, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults }
|
||||
import OpenAI, { APIError, OpenAIError } from "openai"
|
||||
import type { FinalRequestOptions, Headers as OpenAIHeaders } from "openai/core"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import { DEFAULT_OCA_BASE_URL, OCI_HEADER_OPC_REQUEST_ID } from "@/services/auth/oca/utils/constants"
|
||||
import {
|
||||
DEFAULT_EXTERNAL_OCA_BASE_URL,
|
||||
DEFAULT_INTERNAL_OCA_BASE_URL,
|
||||
OCI_HEADER_OPC_REQUEST_ID,
|
||||
} from "@/services/auth/oca/utils/constants"
|
||||
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ApiHandler, type CommonApiHandlerOptions } from ".."
|
||||
@@ -18,6 +22,7 @@ export interface OcaHandlerOptions extends CommonApiHandlerOptions {
|
||||
thinkingBudgetTokens?: number
|
||||
ocaUsePromptCache?: boolean
|
||||
taskId?: string
|
||||
ocaMode?: string // "internal" or "external"
|
||||
}
|
||||
|
||||
export class OcaHandler implements ApiHandler {
|
||||
@@ -70,7 +75,9 @@ export class OcaHandler implements ApiHandler {
|
||||
return super.makeStatusError(status, error, ociErrorMessage, headers)
|
||||
}
|
||||
})({
|
||||
baseURL: options.ocaBaseUrl || DEFAULT_OCA_BASE_URL,
|
||||
baseURL:
|
||||
options.ocaBaseUrl ||
|
||||
(options.ocaMode === "internal" ? DEFAULT_INTERNAL_OCA_BASE_URL : DEFAULT_EXTERNAL_OCA_BASE_URL),
|
||||
apiKey: "noop",
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user