Compare commits

..

1 Commits

Author SHA1 Message Date
abeatrix 6ffba23d3d refactor: convert GitCommitGenerator from module to class
- Convert module pattern to class-based implementation
- Move abort controller to private instance variable
- Add proper method documentation with JSDoc
- Improve encapsulation and code organization
2025-09-02 14:24:11 -07:00
1090 changed files with 25564 additions and 113295 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix showing the ai core exisiting models when resource group field is empty (using the default resource group)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix issue on Account view where balance is fetched twice that cause janky UI
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fixes an issue where thinking text from litellm was not being passed through to Cline thinking UI
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix XML entity escaping in model content processor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Added change to hide the context window usage message from env details when using next gen models and before the usage has reached an elevated state
@@ -1,6 +0,0 @@
---
"claude-dev": patch
---
Docs: Add missing proto generation step in CONTRIBUTING.md and new `npm run dev` script for easier terminal workflow (fixes #7335)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix Ollama connection issue to default endpoint at port 11434
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Created model-family breakouts for deep-planning prompting, and laid groundwork for similar changes for other slash commands.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Use HTTP proxies in more places
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Optimized Cline for GPT-5 model family with an aligned system prompt
@@ -2,4 +2,4 @@
"claude-dev": patch
---
Added Nous Research provider
REfactoring Tool Executor
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add search functionality to API provider dropdown
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove disabled approve / reject buttons from UI.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add "Use custom prompt" option to Ollama provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Nous Hermes 4 model family system prompt
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix AutoApproveModal overflowing issue
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Dify.ai api integration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
support orchestration mode for sap provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve Gemini Rate Limit handling
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adjusted prompting around focus chain, particularly for next-get/native tool calling models.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: Support Anthropic Caching when using LiteLLM
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Prompt changes for deep-planning in windows/powershell
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "PostToolUse running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "PostToolUse response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "PostToolUse hook custom errorMessage"
}
EOF
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "PreToolUse running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "PreToolUse response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "PreToolUse hook custom errorMessage"
}
EOF
-423
View File
@@ -1,423 +0,0 @@
# Cline Hooks Documentation
## Overview
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks can be placed in either:
- **Global hooks directory**: `~/Documents/Cline/Hooks/` (applies to all workspaces)
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to the workspace the repo is part of)
Hooks run automatically when enabled.
## Enabling Hooks
1. Open Cline settings in VSCode
2. Navigate to the Feature Settings section
3. Check the "Enable Hooks" checkbox
4. Hooks must be executable files (on Unix/Linux/macOS use `chmod +x hookname`)
## Available Hooks
### TaskStart Hook
- **When**: Runs when a NEW task is started (not when resuming)
- **Purpose**: Initialize task context, validate task requirements, set up environment
- **Global Location**: `~/Documents/Cline/Hooks/TaskStart`
- **Workspace Location**: `.clinerules/hooks/TaskStart`
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Hooks/TaskResume`
- **Workspace Location**: `.clinerules/hooks/TaskResume`
### TaskCancel Hook
- **When**: Runs when a task is cancelled or a hook is aborted by the user (only if there's actual active work or work was started)
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Hooks/TaskCancel`
- **Workspace Location**: `.clinerules/hooks/TaskCancel`
- **Note**: This hook is NOT cancellable
### TaskComplete Hook (coming soon!)
- **When**: Runs when a task is marked as complete
- **Purpose**: Log completion status, perform final cleanup, generate reports
- **Global Location**: `~/Documents/Cline/Hooks/TaskComplete`
- **Workspace Location**: `.clinerules/hooks/TaskComplete`
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/Hooks/UserPromptSubmit`
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit`
### PreToolUse Hook
- **When**: Runs BEFORE a tool is executed
- **Purpose**: Validate parameters, block execution, or add context
- **Global Location**: `~/Documents/Cline/Hooks/PreToolUse`
- **Workspace Location**: `.clinerules/hooks/PreToolUse`
### PostToolUse Hook
- **When**: Runs AFTER a tool completes
- **Purpose**: Observe results, track patterns, or add context
- **Global Location**: `~/Documents/Cline/Hooks/PostToolUse`
- **Workspace Location**: `.clinerules/hooks/PostToolUse`
### PreCompact Hook (coming soon!)
- **When**: Runs BEFORE the conversation context is compacted/truncated
- **Purpose**: Observe compaction events, log context management, track token usage
- **Global Location**: `~/Documents/Cline/Hooks/PreCompact`
- **Workspace Location**: `.clinerules/hooks/PreCompact`
## Cross-Platform Hook Format
Cline uses a git-style approach for hooks that works consistently across all platforms:
### Hook Files (All Platforms)
- **No file extensions**: Hooks are named exactly `PreToolUse` or `PostToolUse` (no `.bat`, `.cmd`, `.sh` etc.)
- **Shebang required**: First line must be a shebang (e.g., `#!/usr/bin/env bash` or `#!/usr/bin/env node`)
- **Executable on Unix**: On Unix/Linux/macOS, hooks must be executable: `chmod +x PreToolUse`
- **Windows**: Not currently supported.
### How It Works
Like git hooks, Cline executes hook files through a shell that interprets the shebang line:
- On Unix/Linux/macOS: Native shell execution with shebang support
This means:
- ✅ Same hook script works on all platforms
- ✅ Write once, run anywhere
- ✅ Use any scripting language (bash, node, python, etc.)
### Creating Hooks
**On Unix/Linux/macOS:**
```bash
# Create hook file
nano ~/Documents/Cline/Hooks/PreToolUse
# Make executable
chmod +x ~/Documents/Cline/Hooks/PreToolUse
```
## 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": "TaskStart" | "TaskResume" | "TaskCancel" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PreCompact",
"timestamp": "string",
"taskId": "string",
"workspaceRoots": ["string"],
"userId": "string",
"taskStart": { // Only for TaskStart
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"initialTask": "string"
}
},
"taskResume": { // Only for TaskResume
"taskMetadata": {
"taskId": "string",
"ulid": "string"
},
"previousState": {
"lastMessageTs": "string",
"messageCount": "string",
"conversationHistoryDeleted": "string"
}
},
"taskCancel": { // Only for TaskCancel
"taskMetadata": {
"taskId": "string",
"ulid": "string",
"completionStatus": "string"
}
},
"taskComplete": { // Only for TaskComplete
"taskMetadata": {
"taskId": "string",
"ulid": "string"
}
},
"userPromptSubmit": { // Only for UserPromptSubmit
"prompt": "string",
"attachments": ["string"]
},
"preToolUse": { // Only for PreToolUse
"toolName": "string",
"parameters": {}
},
"postToolUse": { // Only for PostToolUse
"toolName": "string",
"parameters": {},
"result": "string",
"success": boolean,
"executionTimeMs": number
},
"preCompact": { // Only for PreCompact
"contextSize": number,
"messagesToCompact": number,
"compactionStrategy": "string"
}
}
```
### Output (via stdout as JSON)
All hooks must return:
```json
{
"cancel": boolean, // Required: false to continue, true to block execution
"contextModification": "string", // Optional: Context for future AI decisions
"errorMessage": "string" // Optional: Error details if blocking
}
```
**Note**: The `cancel` field works as follows:
- `false` (or omitted): Allow execution to continue
- `true`: Block execution and show error message to user
## Hook Execution Limits
- **Timeout**: Hooks must complete within 30 seconds (configurable via `HOOK_EXECUTION_TIMEOUT_MS`)
- **Context Size**: Context modifications are limited to 50KB (configurable via `MAX_CONTEXT_MODIFICATION_SIZE`)
- **Error Handling**: Expected errors (file not found, permission denied, not a directory) are handled silently; unexpected file system errors are propagated
## Common Use Cases
### 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
{
"cancel": true,
"errorMessage": "Cannot create .js files in TypeScript project",
"contextModification": "Use .ts/.tsx extensions only"
}
EOF
exit 0
fi
echo '{"cancel": false}'
```
### 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
{
"cancel": false,
"contextModification": "Created '$path'. Maintain consistency with this file's patterns in future operations."
}
EOF
else
echo '{"cancel": false}'
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
{
"cancel": false,
"contextModification": "Tool '$tool_name' took ${execution_time}ms. Consider optimizing future similar operations."
}
EOF
else
echo '{"cancel": false}'
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 '{"cancel": false}'
```
## Global vs Workspace Hooks
Cline supports two levels of hooks:
### Global Hooks
- **Location**: `~/Documents/Cline/Hooks/` (macOS/Linux)
- **Scope**: Apply to ALL workspaces and projects
- **Use Case**: Organization-wide policies, personal preferences, universal validations
- **Priority**: Order not guaranteed when combined with workspace hooks
### Workspace Hooks
- **Location**: `.clinerules/hooks/` in each workspace root
- **Scope**: Apply only to the specific workspace
- **Use Case**: Project-specific rules, team conventions, repository requirements
- **Priority**: Order not guaranteed when combined with global hooks
### Hook Execution
When multiple hooks exist (global and/or workspace):
- All hooks for a given step are executed **concurrently** using `Promise.all`
- **Execution order is not guaranteed** - hooks run in parallel
- If ALL hooks allow execution (`cancel: false`), the tool proceeds
- If ANY hook blocks (`cancel: true`), execution is blocked
**Result Combination:**
- `cancel`: If ANY hook returns `true`, execution is blocked
- `contextModification`: All context strings are concatenated with double newlines (`\n\n`)
- `errorMessage`: All error messages are concatenated with single newlines (`\n`)
### Setting Up Global Hooks
1. The global hooks directory is automatically created at:
- macOS/Linux: `~/Documents/Cline/Hooks/`
2. Add your hook script:
```bash
# Unix/Linux/macOS
nano ~/Documents/Cline/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
```
3. Enable hooks in Cline settings
### Example: Global + Workspace Hooks
**Global Hook** (applies to all projects):
```bash
#!/usr/bin/env bash
# ~/Documents/Cline/Hooks/PreToolUse
# Universal rule: Never delete package.json
input=$(cat)
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then
echo '{"cancel": true, "errorMessage": "Global policy: Cannot modify package.json"}'
exit 0
fi
echo '{"cancel": false}'
```
**Workspace Hook** (applies to specific project):
```bash
#!/usr/bin/env bash
# .clinerules/hooks/PreToolUse
# Project rule: Only TypeScript files
input=$(cat)
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""')
if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then
echo '{"cancel": true, "errorMessage": "Project rule: Use .ts files only"}'
exit 0
fi
echo '{"cancel": false}'
```
**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently.
## Multi-Root Workspaces
If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined:
- **cancel**: If ANY hook returns `true`, execution is blocked
- **contextModification**: All context modifications are concatenated
- **errorMessage**: All error messages are concatenated
**Note:** No execution order is guaranteed between hooks from different directories.
## Troubleshooting
### Hook Not Running
- 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
- 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
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "TaskCancel running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "TaskCancel response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "TaskCancel hook custom errorMessage"
}
EOF
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "TaskResume running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "TaskResume response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "TaskResume hook custom errorMessage"
}
EOF
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "TaskStart running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "TaskStart response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "TaskStart hook custom errorMessage"
}
EOF
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "UserPromptSubmit running inside local cline/.clinerules/hooks/ directory"
input=$(cat)
echo $input | jq .
for i in {1..5}; do
sleep 1
echo "$i"
done
cat <<EOF
{
"cancel": false,
"contextModification": "UserPromptSubmit response from the local cline/.clinerules/hooks/ directory.",
"errorMessage": "UserPromptSubmit hook custom errorMessage"
}
EOF
+2 -2
View File
@@ -122,9 +122,9 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
title="Previous Updates:"
classNames={{
trigger: "bg-transparent border-0 pl-0 pb-0 w-fit",
title: "font-bold text-(--vscode-foreground)",
title: "font-bold text-[var(--vscode-foreground)]",
indicator:
"text-(--vscode-foreground) mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
"text-[var(--vscode-foreground)] mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
}}>
<ul style={ulStyle}>
<li>
-116
View File
@@ -1,116 +0,0 @@
# Cline Development Environment Variables
# Copy this file to .env and fill in your actual values
# Values should be obtained from 1Password shared vault for development
# ============================================================================
# DEVELOPMENT FLAGS
# Recomend not changing these unless you know what you're doing they are set by the launch.json normally
# ============================================================================
# IS_DEV=true
# CLINE_ENVIRONMENT=local
# ============================================================================
# POSTHOG TELEMETRY (Existing)
# ============================================================================
# Get these values from 1Password shared vault
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
# ============================================================================
# TELEMETRY PROVIDER CONTROL
# ============================================================================
# Control which telemetry providers are active
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
# Set to false to disable Telemetry completely
# ============================================================================
# OPENTELEMETRY (Optional - for advanced telemetry)
# ============================================================================
# OpenTelemetry provides flexible telemetry collection with multiple export options
# Can run alongside PostHog or independently
# Primary focus: Logs (events), with optional metrics support
# Enable OpenTelemetry (set to 1 to enable)
# OTEL_TELEMETRY_ENABLED=1
# Exporters: "console" for local debugging, "otlp" for remote collector
# Logs are the primary signal (recommended)
# OTEL_LOGS_EXPORTER=console
# OTEL_METRICS_EXPORTER=otlp
# OTLP Protocol: "grpc", "http/json", or "http/protobuf"
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# OTLP Endpoint (without /v1/logs or /v1/metrics path - auto-appended)
# For gRPC: use "localhost:4317" (no http:// prefix)
# For HTTP: use "http://localhost:4318"
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
# OTLP Headers (for authentication, e.g., bearer tokens)
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token-here
# Use insecure gRPC connections (for local testing only, NOT for production)
# OTEL_EXPORTER_OTLP_INSECURE=true
# Metric export interval in milliseconds (default: 60000)
# OTEL_METRIC_EXPORT_INTERVAL=10000
# Batch configuration for logs (optional)
# OTEL_LOG_BATCH_SIZE=512 # Max logs per batch (default: 512)
# OTEL_LOG_BATCH_TIMEOUT=5000 # Max wait time in ms (default: 5000)
# OTEL_LOG_MAX_QUEUE_SIZE=2048 # Max queue size (default: 2048)
# Enable detailed export diagnostics (for debugging)
# TEL_DEBUG_DIAGNOSTICS=true
# Advanced: Separate endpoints for metrics and logs (optional)
# OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobuf
# OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://metrics.example.com:4318
# OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc
# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=logs.example.com:4317
# Example configurations:
#
# Console debugging (logs only):
# OTEL_TELEMETRY_ENABLED=1
# OTEL_LOGS_EXPORTER=console
# TEL_DEBUG_DIAGNOSTICS=true
#
# OTLP with gRPC (insecure, for local testing):
# OTEL_TELEMETRY_ENABLED=1
# OTEL_LOGS_EXPORTER=otlp
# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317
# OTEL_EXPORTER_OTLP_INSECURE=true
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
#
# OTLP with HTTP/JSON (production):
# OTEL_TELEMETRY_ENABLED=1
# OTEL_LOGS_EXPORTER=otlp
# OTEL_EXPORTER_OTLP_PROTOCOL=http/json
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
# ============================================================================
# OPTIONAL DEVELOPMENT SETTINGS
# ============================================================================
# Uncomment and modify as needed for development
# Multi-root workspace debugging
# MULTI_ROOT_TRACE=true
# gRPC recorder for testing
# GRPC_RECORDER_ENABLED=true
# GRPC_RECORDER_FILE_NAME=test-recording
# Test mode
# E2E_TEST=true
# IS_TEST=true
# ============================================================================
# USAGE INSTRUCTIONS
# ============================================================================
# 1. Copy this file: cp .env.example .env
# 2. Get PostHog keys from 1Password shared vault
# 3. Update the values in .env
# 4. The .env file is gitignored for security
+2 -3
View File
@@ -1,4 +1,3 @@
/docs/
/.github/ @saoudrizwan @garoth @sjf
/README.md @saoudrizwan @nickbaumann98
/src/core/storage/ @celestial-vault
/.github/ @saoudrizwan @dcbartlett
/README.md @saoudrizwan @nickbaumann98
+61 -67
View File
@@ -1,70 +1,64 @@
name: 🐛 Bug Report
description: File a bug report
labels: ['bug']
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: plugin-type
attributes:
label: Plugin Type
description: Which plugin are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
- CLI
default: 0
validations:
required: true
- type: input
id: cline-version
attributes:
label: Cline Version
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
placeholder: 'e.g., 1.2.3'
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
validations:
required: false
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: system-info
attributes:
label: System Information
description: What operating system and hardware are you using?
placeholder: |
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
Hardware: CPU, GPU, RAM specifications if relevant
e.g.,
OS: Windows 11
CPU: Intel Core i7-11700K
GPU: NVIDIA GeForce RTX 3070
RAM: 32GB DDR4
validations:
required: false
- type: markdown
attributes:
value: |
**Important:** All bug reports must be reproducible using Claude 4 Sonnet. Cline uses complex prompts so less capable models may not work as expected.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant API REQUEST output
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
validations:
required: true
- type: textarea
id: system-info
attributes:
label: System Information
description: What operating system and hardware are you using?
placeholder: |
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
Hardware: CPU, GPU, RAM specifications if relevant
e.g.,
OS: Windows 11
CPU: Intel Core i7-11700K
GPU: NVIDIA GeForce RTX 3070
RAM: 32GB DDR4
validations:
required: true
- type: input
id: cline-version
attributes:
label: Cline Version
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
placeholder: "e.g., 1.2.3"
validations:
required: true
@@ -1,53 +0,0 @@
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']
});
}
}
-82
View File
@@ -1,82 +0,0 @@
name: "Publish Nightly Release"
on:
schedule:
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
permissions:
contents: write
packages: write
checks: write
pull-requests: write
jobs:
test:
uses: ./.github/workflows/test.yml
publish:
needs: test
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline'
runs-on: ubuntu-latest
environment: PublishNightly
steps:
- uses: actions/checkout@v4
- name: Check for recent commits
run: |
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
echo "No commits in last 24 hours, exiting"
exit 0
fi
echo "Found recent commits, proceeding with build"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Publish Extension as Pre-release
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: console,otlp
OTEL_METRICS_EXPORTER: console,otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run publish:marketplace:nightly
-9
View File
@@ -95,15 +95,6 @@ jobs:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: console,otlp
OTEL_METRICS_EXPORTER: console,otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: |
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
+106 -156
View File
@@ -1,9 +1,6 @@
name: Tests
on:
push:
branches:
- main
workflow_dispatch:
pull_request:
branches:
@@ -17,45 +14,7 @@ permissions:
pull-requests: write # Needed to add comments/annotations to PRs
jobs:
quality-checks:
runs-on: ubuntu-latest
name: Quality Checks
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
test:
needs: quality-checks
strategy:
fail-fast: false
matrix:
@@ -74,6 +33,18 @@ jobs:
with:
node-version: 22
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
@@ -81,6 +52,7 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
@@ -96,45 +68,54 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Install xvfb on Linux
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Set up NPM on Windows
if: runner.os == 'Windows'
run: |
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
# Build the extension and tests (without redundant checks)
- name: Type Check
run: npm run check-types
- name: Lint Check
run: npm run lint
- name: Format Check
run: npm run format
# Build the extension before running tests
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
run: npm run pretest
- name: Unit Tests with coverage - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
- name: Unit Tests
run: npm run test:unit
# Run extension tests with coverage
- name: Extension Integration Tests with Coverage
id: extension_coverage
continue-on-error: true
run: |
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
- name: Unit Tests - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
npm run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a npm run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: npm run test:integration
node ./scripts/test-ci.js 2>&1 | tee extension_coverage.txt
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
# Run webview tests with coverage
- name: Webview Tests with Coverage
id: webview_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
id: webview_coverage
continue-on-error: true
run: |
cd webview-ui
npm run test:coverage
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage 2>&1 | tee webview_coverage.txt
cd ..
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
# Save coverage reports as artifacts (workflow-scoped)
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
# Only upload artifacts on Linux - We only need coverage from one OS
@@ -142,21 +123,52 @@ jobs:
with:
name: pr-coverage-reports
path: |
coverage-unit/lcov.info
webview-ui/coverage/lcov.info
extension_coverage.txt
webview-ui/webview_coverage.txt
test-platform-integration:
needs: quality-checks
# Set the check as failed if any of the tests failed
- name: Check for test failures
run: |
# Check if any of the test steps failed
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
if [ "${{ steps.extension_coverage.outcome }}" != "success" ]; then
echo "Extension Integration Tests failed, see previous step for test output."
fi
if [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
echo "Webview Tests failed, see previous step for test output."
fi
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
exit 1
fi
coverage:
needs: test
runs-on: ubuntu-latest
# Only run on PRs to main branch
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for accurate comparison
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
@@ -164,6 +176,7 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
@@ -171,14 +184,6 @@ jobs:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
# Cache testing-platform dependencies
- name: Cache testing-platform dependencies
uses: actions/cache@v4
id: testing-platform-cache
with:
path: testing-platform/node_modules
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
@@ -187,85 +192,30 @@ jobs:
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Build the extension before running tests
- name: Build Extension
run: npm run compile
- name: Build CLI binaries
run: npm run compile-cli-all-platforms
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Compile NPM package
run: npm run compile-standalone-npm
- name: Install testing platform dependencies
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
continue-on-error: true
timeout-minutes: 7
# Temporarily wrapping the test command to always return a neutral exit code.
# This prevents the job from showing as failed and avoids distracting developers
# until the integration tests are ready to be enforced.
run: |
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
with:
name: test-platform-integration-core-coverage
path: coverage/**/lcov.info
qlty:
needs: [test, test-platform-integration]
runs-on: ubuntu-latest
# Run on PRs to main, pushes to main, and manual dispatches
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download unit tests coverage reports
# Download coverage artifacts from test job
- name: Download Coverage Reports
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: .
path: . # Download to root directory to match expected paths
- name: Upload core unit tests coverage to Qlty
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
coverage-unit/lcov.info
tag: unit:core
# Process coverage workflow
- name: Process coverage workflow
id: coverage
run: |
# Extract PR number from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
- name: Upload webview-ui unit tests coverage to Qlty
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
webview-ui/coverage/lcov.info
tag: unit:webview-ui
add-prefix: webview-ui/
- 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 }}
files: integration-core-coverage-reports/**/lcov.info
tag: integration:core
# Run the coverage workflow from root directory
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
--base-branch ${{ github.base_ref }} \
--pr-number $PR_NUMBER \
--repo $GITHUB_REPOSITORY \
--token ${{ secrets.GITHUB_TOKEN }} \
--verbose
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,53 +0,0 @@
name: Trigger Jetbrains Plugin <-> Cline Tests
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: jetbrains-trigger-${{ github.event.number }}
cancel-in-progress: true
jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: 1998650
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
owner: cline
repositories: intellij-plugin
- name: Trigger IntelliJ Plugin Integration Test
run: |
curl -X POST \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "User-Agent: cline-pr-trigger" \
-H "Content-Type: application/json" \
https://api.github.com/repos/cline/intellij-plugin/dispatches \
-d @- <<EOF
{
"event_type": "cline-pr-check",
"client_payload": {
"pr_number": "${{ github.event.number }}",
"branch_name": "${{ github.head_ref }}",
"action": "${{ github.event.action }}",
"sha": "${{ github.event.pull_request.head.sha }}",
"pr_title": ${{ toJSON(github.event.pull_request.title) }},
"pr_url": "${{ github.event.pull_request.html_url }}"
}
}
EOF
- name: Log trigger details
run: |
echo "Triggered IntelliJ Plugin integration test for:"
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
echo " Branch: ${{ github.head_ref }}"
echo " Action: ${{ github.event.action }}"
echo " SHA: ${{ github.event.pull_request.head.sha }}"
+3 -3
View File
@@ -20,13 +20,10 @@ webview-ui/src/**/*.js.map
# Ignore coverage directories and files
coverage
coverage-unit
.nyc_output
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
*evals.env
.env
## Generated files ##
src/generated/
@@ -35,3 +32,6 @@ webview-ui/src/services/grpc-client.ts
# E2E Tests
test-results
## CLI pre-release ##
/cli
+1 -1
View File
@@ -1 +1 @@
lint-staged
lint-staged --no-stash
-48
View File
@@ -1,48 +0,0 @@
{
"all": true,
"check-coverage": false,
"reporter": [
"text",
"lcov"
],
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.d.ts",
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/.nyc_output/**",
"**/.vscode-test/**",
"**/tests-results/**",
"src/test/**",
"src/generated/**",
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/build/**",
"**/coverage/**",
"**/coverage-unit/**",
"**/proto/**",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
"**/vite-env.d.ts",
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}"
],
"extension": [
".ts",
".js"
],
"cache": true,
"sourceMap": true,
"instrument": true,
"report-dir": "./coverage-unit"
}
+8 -61
View File
@@ -12,14 +12,12 @@
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-workspace-trust",
"--disable-extensions", // Avoid conflicts with installed extensions
"${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -33,14 +31,12 @@
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-workspace-trust",
"--disable-extensions", // Avoid conflicts with installed extensions
"${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -54,14 +50,12 @@
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--disable-workspace-trust",
"--disable-extensions", // Avoid conflicts with installed extensions
"${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
@@ -77,7 +71,7 @@
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
"--profile-temp",
"--sync=off",
"--disable-extensions", // Avoid conflicts with installed extensions
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}"
],
@@ -87,7 +81,6 @@
"preLaunchTask": "clean-tmp-user",
"internalConsoleOptions": "openOnSessionStart",
"postDebugTask": "stop",
"envFile": "${workspaceFolder}/.env",
"env": {
"IS_DEV": "true",
"TEMP_PROFILE": "true",
@@ -98,7 +91,7 @@
{
"type": "node",
"request": "launch",
"name": "Debug Test Standalone Core Api Server (test:sca-server)",
"name": "Run cline-core service",
"skipFiles": [
"<node_internals>/**"
],
@@ -107,64 +100,18 @@
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"cwd": "${workspaceFolder}/dist-standalone",
"outFiles": [
"${workspaceFolder}/dist/**/*.js",
"${workspaceFolder}/dist-standalone/**/*.js"
],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/.env",
"env": {
"PROTOBUS_PORT": "26040",
"HOSTBRIDGE_PORT": "26041",
"WORKSPACE_DIR": "${workspaceFolder}",
"E2E_TEST": "true",
"CLINE_ENVIRONMENT": "local"
// Turns on grpc debug log.
//"GRPC_TRACE": "all",
//"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npx",
"runtimeArgs": [
"mocha"
],
"args": [
"--require",
"ts-node/register",
"--require",
"source-map-support/register",
"--require",
"./src/test/requires.ts",
"--exit",
"${file}"
],
"envFile": "${workspaceFolder}/.env",
"env": {
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
"NODE_ENV": "test",
"IS_DEV": "true",
"CLINE_ENVIRONMENT": "local"
},
"console": "integratedTerminal",
"internalConsoleOptions": "openOnSessionStart"
"program": "cline-core.js"
}
]
}
+11 -6
View File
@@ -21,11 +21,16 @@
},
// Enable Lint and format using Biome
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.removeUnused.biome": "always",
"source.removeUnusedImports": "always",
"source.organizeImports.biome": "always"
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
-10
View File
@@ -18,7 +18,6 @@ tsconfig*.json
eslint-rules/**
.github/**
.husky/**
.env
# Custom
**/demo.gif
@@ -37,9 +36,6 @@ buf.yaml
.changeset/
.clinerules/
# Include specific file needed for Background Exec mode
!standalone/runtime-files/vscode/enhanced-terminal.js
# Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore)
webview-ui/src/**
webview-ui/public/**
@@ -68,9 +64,3 @@ old_docs/**
e2e-build.mjs
e2e.vsix
test-results/
# Ignore Storybook files
**/*.stories.tsx
*storybook.log
storybook-static
**/StorybookDecorator.tsx
-204
View File
@@ -1,209 +1,5 @@
# Changelog
## [3.36.1]
- fix: remove native tool calling support from Gemini and XAI provider due to invalid tool names issues
- fix: disable native tool callings for grok code models
- Add MCP tool usage to GLM
- Removes reasoning_details content field from Anthropic providers
## [3.36.0]
- Add: Hooks allow you to inject custom logic into Cline's workflow
- Add: new provider AIhubmix
- Add: Use http_proxy, https_proxy and no_proxy in JetBrains
- Fix: Oca Token Refresh logic
- Fix: issues where assistant message with empty content is added to conversation history
- Fix: bug where the checkbox shows in the model selector dropdown
- Fix: Switch from defaultUserAgentProvider to customUserAgent for Bedrock
- Fix: support for `<think>` tags for better compatibility with open-source models
- Fix: refinements to the GLM-4.6 system prompt
## [3.35.1]
- Add: Hicap API integration as provider
- Fix: enable Add Header button in OpenAICompatibleProvider UI
- Fix: Remove orphaned tool_results after truncation and empty content field issues in native tool call
- Fix: render model description in markdown
## [3.35.0]
- Add native tool calling support with configurable setting.
- Auto-approve is now always-on with a redesigned expanding menu. Settings simplified and notifications moved to General Settings.
- added zai-glm-4.6 as a Cerebras model
- Created GPT5 family specific system prompt template
- Fix: show reasoning budget slider to models with valid thinking config
- Requesty base URL, and API key fixes
- Delete all Auth Tokens when logging out
- Support for <think> tags for models that prefer that over <thinking>
## [3.34.1]
- Added support for MiniMax provider with MiniMax-M2 model
- Remove Cline/code-supernova-1-million model
- Changes to allow users to manually enter model names (eg. presets) when using OpenRouter
## [3.34.0]
- Cline Teams is now free through 2025 for unlimited users. Includes Jetbrains, RBAC, centralized billing and more.
- Use the “exacto” versions of GLM-4.6, Kimi-K2, and Qwen3-Coder in the Cline provider for the best balance of cost, speed, accuracy and tool-calling.
## [3.33.1]
- Fix CLI installation copy text
## [3.33.0]
- Added Cline CLI (Preview)
- Added Subagent support (Experimental)
- Added Multi-Root Workspaces support (Enable in feature settings)
- Add auto-retry with exponential backof for failed API requests
## [3.32.8]
- Add Claude Haiku 4.5 support
## [3.32.7]
- Add JP and Global inference profile options to AWS Bedrock
- Adding Improvements to VSCode multi root workspaces
- Added markdown support to focus chain text, allowing the model to display more interesting focus chains
## [3.32.6]
- Add experimental support for VSCode multi root workspaces
- Add Claude Sonnet 4.5 to Claude Code provider
- Add Glm 4.6 to Z AI provider
## [3.32.5]
- Improve thinking budget slider UI to take up less space
- Fix Vercel provider cost note and sign-up url
- Fix repeated API error 400 in SAP AI Core provider
- Add us-west-1 to Amazon Bedrock regions
- Fix OCA provider refresh logic
## [3.32.4]
- Add 1m context window support to Claude Sonnet 4.5
- Add Claude Sonnet 4.5 to GCP Vertex
- Add prompt caching support for OpenRouter accidental `anthropic/claude-4.5-sonnet` model ID
## [3.32.3]
- Add Claude Sonnet 4.5 to Bedrock provider
- Add Alert banner for new Claude Sonnet 4.5 model
## [3.32.2]
- Add Claude Sonnet 4.5 to Cline/OpenRouter/Anthropic providers
- Add /task deep link handler
## [3.32.1]
- Preserve reasoning traces for Cline/OpenRouter/Anthropic providers to maintain conversation integrity
- Add automatically retry on rate limit errors with SAP AI Core provider
- Fix Cline accounts using stale id token at refresh response
- Minor UI improvements to Settings and Task Header
## [3.32.0]
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
- Changes to inform Cline about commands that are available on your system
## [3.31.1]
- Version bump
## [3.31.0]
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
- Fix Oracle Code Assist provider issues
## [3.30.3]
- Add Oracle Code Assist provider
## [3.30.2]
- Fix UI tests
## [3.30.1]
- Fix model list not being updated in time for user to use shortcut button to update model to stealth model
- Fix flicker issue when switching modes
- Fix Sticky header in settings view overlaping with content on scroll
- Add experimental yolo mode feature that disables all user approvals and automatically executes a task and navigates through plan to act mode until the task is complete
## [3.30.0]
- Add code-supernova stealth model
## [3.29.2]
- Fix: Reverted change that caused formatting issues
- Fix: Moonshot - Pass max_tokens value to provider
## [3.29.1]
- Changeset bump + Announcement banner update
## [3.29.0]
- Updated Baseten provider to fetch models from server
- Fix: Updated insufficient balance URL for easy Cline balance top-ups
- Accessibility: Improvements to screen readers in MCP, Cline Rules, workflows, and history views.
## [3.28.4]
- Fix bug where some Windows machines had API request hanging
- Fix bug where 'Proceed while running' action button would be disabled after running an interactive command
- Fix prompt cache info not being displayed in History
## [3.28.3]
- Fixed issue with start new task button
- Feature to generate commit message for staged changes, with unstaged as fallback
## [3.28.2]
- Fix for focus chain settings
## [3.28.1]
- Requesty: use base URL to get models and API keys
- Removed focus chain feature flag
## [3.28.0]
- Synchronized Task History: Real-time task history synchronization across all Cline instances
- Optimized GPT-5 Integration: Fine-tuned system prompts for improved performance with GPT-5 model family
- Deep Planning Improvements: Optimized prompts for Windows/PowerShell environments and dependency exclusion
- Streamlined UI Experience: ESC key navigation, cleaner approve/reject buttons, and improved editor panel focus
- Smart Provider Search: Improved search functionality in API provider dropdown for faster model selection
- Added per-provider thinking tokens configurability
- Added Ollama custom prompt options
- Enhanced SAP AI Core Provider: Orchestration mode support and improved model visibility
- Added Dify.ai API Integration
- SambaNova Updates: Added DeepSeek-V3.1 model
- Better Gemini rate limit handling
- OpenAI Reasoning Effort: Minimal reasoning effort configuration for OpenAI models
- Fixed LiteLLM Caching: Anthropic caching compatibility when using LiteLLM
- Fixed Ollama default endpoint connections
- Fixed AutoApprove menu overflow
- Fixed extended thinking token issue with Anthropic models
- Fixed issue with slash commands removing text from prompt
## [3.27.2]
- Remove `grok-code-fast-1` promotion deadline
## [3.27.1]
- Add new Kimi K2 model to groq and moonshot providers
## [3.27.0]
- Fix `grok-code-fast-1` model information
+2 -37
View File
@@ -46,11 +46,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
```bash
npm run install:all
```
4. Generate Protocol Buffer files (required before first build):
```bash
npm run protos
```
5. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
@@ -78,6 +74,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
4. Testing
- Run `npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
- Run `npm run test:ci` to run tests locally
### Extension
@@ -89,10 +86,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. **Local Development**
- Run `npm run install:all` to install dependencies
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
- Run `npm run test` to run tests locally
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
- Before submitting PR, run `npm run format:fix` to format your code
3. **Linux-specific Setup**
@@ -162,36 +157,6 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
- Update existing tests if your changes affect them
- Include both unit tests and integration tests where appropriate
**End-to-End (E2E) Testing**
Cline includes comprehensive E2E tests using Playwright that simulate real user interactions with the extension in VS Code:
- **Running E2E tests:**
```bash
npm run test:e2e # Build and run all E2E tests
npm run e2e # Run tests without rebuilding
npm run test:e2e -- --debug # Run with interactive debugger
```
- **Writing E2E tests:**
- Tests are located in `src/test/e2e/`
- Use the `e2e` fixture for single-root workspace tests
- Use `e2eMultiRoot` fixture for multi-root workspace tests
- Follow existing patterns in `auth.test.ts`, `chat.test.ts`, `diff.test.ts`, and `editor.test.ts`
- See `src/test/e2e/README.md` for detailed documentation
- **Debug mode features:**
- Interactive Playwright Inspector for step-by-step debugging
- Record new interactions and generate test code automatically
- Visual VS Code instance for manual testing
- Element inspection and selector validation
- **Test environment:**
- Automated VS Code setup with Cline extension loaded
- Mock API server for backend testing
- Temporary workspaces with test fixtures
- Video recording for failed tests
4. **Version Management with Changesets**
- Create a changeset for any user-facing changes using `npm run changeset`
+2 -2
View File
@@ -2,7 +2,7 @@
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
# Cline \#1 on OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
@@ -43,7 +43,7 @@ Thanks to [Claude Sonnet's agentic coding capabilities](https://www.anthropic.c
4. When a task is completed, Cline will present the result to you with a terminal command like `open -a "Google Chrome" index.html`, which you run with a click of a button.
> [!TIP]
> Follow [this guide](https://docs.cline.bot/features/customization/opening-cline-in-sidebar) to open Cline on the right side of your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
> Use the `CMD/CTRL + Shift + P` shortcut to open the command palette and type "Cline: Open In New Tab" to open the extension as a tab in your editor. This lets you use Cline side-by-side with your file explorer, and see how he changes your workspace more clearly.
---
+13 -20
View File
@@ -70,7 +70,7 @@
"noControlCharactersInRegex": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "info",
"noAssignInExpressions": "info"
"noAssignInExpressions": "warn"
},
"complexity": {
"noUselessConstructor": "off",
@@ -82,7 +82,7 @@
"noStaticOnlyClass": "off"
},
"security": {
"noDangerouslySetInnerHtml": "info"
"noDangerouslySetInnerHtml": "warn"
}
}
},
@@ -114,17 +114,16 @@
"files": {
"includes": [
"**",
"!**/dist",
"!**/dist-*",
"!**/out",
"!**/evals",
"!**/playwright",
"!**/test-results",
"!**/node_modules",
"!**/webview-ui/build",
"!**/generated",
"!**/proto",
"!**/tests/specs"
"!**/dist/**",
"!**/dist-*/**",
"!**/out/**",
"!**/evals/**",
"!**/playwright/**",
"!**/test-results/**",
"!**/node_modules/**",
"!**/webview-ui/build/**",
"!**/generated/**",
"!**/proto/**"
]
},
"plugins": [
@@ -136,12 +135,7 @@
"**",
"!**/hosts/vscode/**",
"!**/test/**",
"!**/*.test.ts",
"!src/dev/**",
"!src/extension.ts",
"!src/integrations/git/commit-message-generator.ts",
"!src/integrations/terminal/**",
"!src/core/controller/ui/openWalkthrough.ts"
"!src/extension.ts"
],
"plugins": [
"src/dev/grit/vscode-api.grit"
@@ -154,7 +148,6 @@
"!src/core/storage/FileContextTracker.ts",
"!src/core/context/context-tracking/FileContextTracker.ts",
"!src/common.ts",
"!src/services/logging/distinctId.ts",
"!src/core/storage/utils/state-helpers.ts",
"!src/extension.ts"
],
-2
View File
@@ -1,2 +0,0 @@
cline-core-debug.log
bin/*
-72
View File
@@ -1,72 +0,0 @@
# Cline CLI
```
/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\
\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_
\:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\
\:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_
\:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\
\_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/
```
Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more.
## Installation
Install Cline globally using npm:
```bash
npm install -g cline
```
## Usage
```bash
cline
```
This will start the Cline CLI interface where you can interact with the autonomous coding agent.
## Features
- **Autonomous Coding**: AI-powered code generation, editing, and refactoring
- **File Operations**: Create, read, update, and delete files and directories
- **Command Execution**: Run shell commands and scripts
- **Browser Automation**: Interact with web pages and applications
- **Multi-Model Support**: Works with Anthropic Claude, OpenAI GPT, and other AI models
- **MCP Integration**: Extensible through Model Context Protocol servers
- **Project Understanding**: Analyzes codebases to provide context-aware assistance
## Requirements
- Node.js 18.0.0 or higher
- Supported platforms: macOS, Linux. Windows soon
- Supported architectures: x64, arm64
## Configuration
Cline can be configured through:
- Environment variables
- Configuration files
- Command-line arguments
See the [main documentation](https://cline.bot) for detailed configuration options.
## Links
- **Website**: [https://cline.bot](https://cline.bot)
- **Documentation**: [https://docs.cline.bot](https://docs.cline.bot)
- **GitHub**: [https://github.com/cline/cline](https://github.com/cline/cline)
- **VSCode Extension**: Available in the VSCode Marketplace
- **JetBrains Extension**: Available in the JetBrains Marketplace
## License
Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for details.
## Support
- Report issues: [GitHub Issues](https://github.com/cline/cline/issues)
- Community: [GitHub Discussions](https://github.com/cline/cline/discussions)
- Documentation: [docs.cline.bot](https://docs.cline.bot)
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/cline/cli/pkg/hostbridge"
)
var (
port int
verbose bool
)
func main() {
rootCmd := &cobra.Command{
Use: "cline-host",
Short: "Cline Host Bridge Service",
Long: `A simple host bridge service that provides host operations for Cline Core.`,
RunE: runServer,
}
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func runServer(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Create gRPC hostbridge service
service := hostbridge.NewGrpcServer(port, verbose)
// Handle graceful shutdown
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
if verbose {
log.Println("Shutting down hostbridge server...")
}
cancel()
}()
// Start server
if verbose {
log.Printf("Starting Cline Host Bridge on port %d", port)
}
// Run the service
if err := service.Start(ctx); err != nil {
return fmt.Errorf("failed to run service: %w", err)
}
return nil
}
-348
View File
@@ -1,348 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/cline/cli/pkg/cli"
"github.com/cline/cli/pkg/cli/auth"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
"github.com/spf13/cobra"
)
var (
coreAddress string
verbose bool
outputFormat string
// Task creation flags (for root command)
images []string
files []string
mode string
settings []string
yolo bool
oneshot bool
)
func main() {
rootCmd := &cobra.Command{
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Long: `A command-line interface for interacting with Cline AI coding assistant.
Start a new task by providing a prompt:
cline "Create a new Python script that prints hello world"
Or pipe a prompt via stdin:
echo "Create a todo app" | cline
cat prompt.txt | cline --yolo
Or run with no arguments to enter interactive mode:
cline
This CLI also provides task management, configuration, and monitoring capabilities.
For detailed documentation including all commands, options, and examples,
see the manual page: man cline`,
Args: cobra.ArbitraryArgs,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" {
return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat)
}
return global.InitializeGlobalConfig(&global.GlobalConfig{
Verbose: verbose,
OutputFormat: outputFormat,
CoreAddress: coreAddress,
})
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
var instanceAddress string
// If --address flag not provided, start instance BEFORE getting prompt
if !cmd.Flags().Changed("address") {
if global.Config.Verbose {
fmt.Println("Starting new Cline instance...")
}
instance, err := global.Clients.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start new instance: %w", err)
}
instanceAddress = instance.Address
if global.Config.Verbose {
fmt.Printf("Started instance at %s\n\n", instanceAddress)
}
// Set up cleanup on exit
defer func() {
if global.Config.Verbose {
fmt.Println("\nCleaning up instance...")
}
registry := global.Clients.GetRegistry()
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
if global.Config.Verbose {
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
}
}
}()
// Check if user has credentials configured
if !isUserReadyToUse(ctx, instanceAddress) {
// Create renderer for welcome messages
renderer := display.NewRenderer(global.Config.OutputFormat)
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
// Check if user cancelled - exit cleanly
if err == huh.ErrUserAborted {
return nil
}
return fmt.Errorf("auth setup failed: %w", err)
}
// Re-check after auth wizard
if !isUserReadyToUse(ctx, instanceAddress) {
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
}
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
}
} else {
// User specified --address flag, use that
instanceAddress = coreAddress
}
// Get content from both args and stdin
prompt, err := getContentFromStdinAndArgs(args)
if err != nil {
return fmt.Errorf("failed to read prompt: %w", err)
}
// If no prompt from args or stdin, show interactive input
if prompt == "" {
// Pass the mode flag to banner so it shows correct mode
prompt, err = promptForInitialTask(ctx, instanceAddress, mode)
if err != nil {
// Check if user cancelled - exit cleanly without error
if err == huh.ErrUserAborted {
return nil
}
return err
}
if prompt == "" {
return fmt.Errorf("prompt required")
}
}
// If oneshot mode, force plan mode and yolo
if oneshot {
mode = "plan"
yolo = true
}
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
Images: images,
Files: files,
Mode: mode,
Settings: settings,
Yolo: yolo,
Address: instanceAddress,
Verbose: verbose,
})
},
}
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)")
// Task creation flags (only apply when using root command with prompt)
rootCmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files")
rootCmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files")
rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan")
rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)")
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
rootCmd.AddCommand(cli.NewTaskCommand())
rootCmd.AddCommand(cli.NewInstanceCommand())
rootCmd.AddCommand(cli.NewConfigCommand())
rootCmd.AddCommand(cli.NewVersionCommand())
rootCmd.AddCommand(cli.NewAuthCommand())
rootCmd.AddCommand(cli.NewLogsCommand())
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
os.Exit(1)
}
}
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) {
// Show session banner before the initial input
showSessionBanner(ctx, instanceAddress, modeFlag)
var prompt string
// Create custom theme with mode-colored cursor and title
theme := huh.ThemeCharm()
// Set cursor and title color based on mode
modeColor := lipgloss.Color("3") // Yellow for plan
if modeFlag == "act" {
modeColor = lipgloss.Color("39") // Blue for act
}
theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor)
theme.Focused.Title = theme.Focused.Title.Foreground(modeColor)
form := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Start a new Cline task").
Description("What would you like Cline to help you with?").
Placeholder("e.g., Create a REST API with authentication...").
Lines(5).
Value(&prompt),
),
).WithWidth(48).WithTheme(theme)
err := form.Run()
if err != nil {
// Check if user cancelled with Control-C
if err == huh.ErrUserAborted {
// Return a special error that indicates clean cancellation
// This allows deferred cleanup to run
return "", huh.ErrUserAborted
}
return "", err
}
return strings.TrimSpace(prompt), nil
}
// showSessionBanner displays session info before initial prompt
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
bannerInfo := display.BannerInfo{
Version: global.CliVersion,
Mode: modeFlag, // Use the mode from command flag, not state
}
// If mode is empty, default to "plan"
if bannerInfo.Mode == "" {
bannerInfo.Mode = "plan"
}
// Get current working directory (this is what Cline will use)
if cwd, err := os.Getwd(); err == nil {
bannerInfo.Workdir = cwd
}
// Get provider/model using auth functions (same logic as auth menu)
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
if err == nil {
if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil {
// Show provider/model for the mode we'll be using
var providerDisplay *auth.ProviderDisplay
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
providerDisplay = providerList.PlanProvider
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
providerDisplay = providerList.ActProvider
}
if providerDisplay != nil {
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
bannerInfo.ModelID = providerDisplay.ModelID
}
}
}
// Render and display banner
banner := display.RenderSessionBanner(bannerInfo)
fmt.Println(banner)
fmt.Println() // Extra spacing before form
}
// isUserReadyToUse checks if the user has completed initial setup
// Returns true if welcomeViewCompleted flag is set OR user is authenticated
// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid)
func isUserReadyToUse(ctx context.Context, instanceAddress string) bool {
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
if err != nil {
return false
}
// Get state
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return false
}
// Parse state JSON
stateMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
return false
}
// Check 1: welcomeViewCompleted flag
if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted {
return true
}
// Check 2: Is user authenticated? (matches extension's || user?.uid check)
if userInfo, ok := stateMap["userInfo"].(map[string]interface{}); ok {
if uid, ok := userInfo["uid"].(string); ok && uid != "" {
return true
}
}
return false
}
// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them
func getContentFromStdinAndArgs(args []string) (string, error) {
var content strings.Builder
// Add command line args first (if any)
if len(args) > 0 {
content.WriteString(strings.Join(args, " "))
}
// Check if stdin has data
stat, err := os.Stdin.Stat()
if err != nil {
return "", fmt.Errorf("failed to stat stdin: %w", err)
}
// Check if data is being piped to stdin
if (stat.Mode() & os.ModeCharDevice) == 0 {
// Only try to read if there's actually data available
if stat.Size() > 0 {
stdinBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read from stdin: %w", err)
}
stdinContent := strings.TrimSpace(string(stdinBytes))
if stdinContent != "" {
if content.Len() > 0 {
content.WriteString(" ")
}
content.WriteString(stdinContent)
}
}
}
return content.String(), nil
}
-154
View File
@@ -1,154 +0,0 @@
package e2e
import (
"context"
"encoding/json"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// 2. Multi-instance start: default_instance remains the first started.
func TestMultiInstanceDefaultUnchanged(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start first instance and wait healthy
_ = mustRunCLI(ctx, t, "instance", "new")
out1 := listInstancesJSON(ctx, t)
if len(out1.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
}
firstAddr := out1.CoreInstances[0].Address
waitForAddressHealthy(t, firstAddr, defaultTimeout)
// Start second instance
_ = mustRunCLI(ctx, t, "instance", "new")
out2 := listInstancesJSON(ctx, t)
if len(out2.CoreInstances) < 2 {
t.Fatalf("expected at least 2 instances, got %d", len(out2.CoreInstances))
}
// Default should remain the first started address
if out2.DefaultInstance != firstAddr {
t.Fatalf("default changed; expected %s, got %s", firstAddr, out2.DefaultInstance)
}
}
// 6. Default.json update after removal of current default
func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start two instances
_ = mustRunCLI(ctx, t, "instance", "new")
_ = mustRunCLI(ctx, t, "instance", "new")
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) < 2 {
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
}
// Choose second as new default
target := out.CoreInstances[1]
waitForAddressHealthy(t, target.Address, defaultTimeout)
// Set as default
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
// Verify default switched
out = listInstancesJSON(ctx, t)
if out.DefaultInstance != target.Address {
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
}
// Kill the default instance using runtime PID discovery
corePID := getCorePID(t, target.Address)
if corePID <= 0 {
t.Fatalf("could not find PID for core process at %s", target.Address)
}
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
// Wait for removal
waitForAddressRemoved(t, target.Address, longTimeout)
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
findAndKillHostProcess(t, target.HostPort())
// Ensure default_instance updated to another available instance (or removed if none remain)
out = listInstancesJSON(ctx, t)
// If there are instances left, default_instance must be one of them
if len(out.CoreInstances) > 0 {
found := false
for _, it := range out.CoreInstances {
if out.DefaultInstance == it.Address {
found = true
break
}
}
if !found {
t.Fatalf("default_instance %s not set to an existing instance after removal", out.DefaultInstance)
}
} else {
// No instances remain; cli-default-instance.json should be removed
clineDir := getClineDir(t)
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if _, err := os.Stat(defPath); err == nil {
t.Fatalf("expected cli-default-instance.json removed when no instances remain")
}
}
// Also verify cli-default-instance.json on disk reflects the in-memory default (if any)
clineDir := getClineDir(t)
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if len(out.CoreInstances) > 0 {
raw, err := os.ReadFile(defPath)
if err != nil {
t.Fatalf("read cli-default-instance.json: %v", err)
}
var tmp struct {
DefaultInstance string `json:"default_instance"`
}
if err := json.Unmarshal(raw, &tmp); err != nil {
t.Fatalf("unmarshal cli-default-instance.json: %v", err)
}
if tmp.DefaultInstance != out.DefaultInstance {
t.Fatalf("cli-default-instance.json mismatch: file=%s list=%s", tmp.DefaultInstance, out.DefaultInstance)
}
}
}
// 11. SQLite database missing (edge): list succeeds and returns empty set
func TestRegistryDirMissingEdge(t *testing.T) {
clineDir := setTempClineDir(t)
// Remove the settings directory entirely (which contains locks.db)
settingsDir := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER)
if err := os.RemoveAll(settingsDir); err != nil {
t.Fatalf("RemoveAll(%s): %v", common.SETTINGS_SUBFOLDER, err)
}
// Listing should succeed and return empty results
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) != 0 {
t.Fatalf("expected 0 instances after removing %s dir, got %d", common.SETTINGS_SUBFOLDER, len(out.CoreInstances))
}
// Ensure cli-default-instance.json not present
defPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if _, err := os.Stat(defPath); err == nil {
t.Fatalf("expected no cli-default-instance.json after removing %s dir", common.SETTINGS_SUBFOLDER)
}
}
-378
View File
@@ -1,378 +0,0 @@
package e2e
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
)
const (
defaultTimeout = 30 * time.Second
longTimeout = 60 * time.Second
pollInterval = 250 * time.Millisecond
instancesBinRel = "../bin/cline"
)
func repoAwareBinPath(t *testing.T) string {
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd error: %v", err)
}
// cli/e2e -> cli/bin/cline
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
if _, err := os.Stat(p); err != nil {
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
}
return p
}
func setTempClineDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
clineDir := filepath.Join(dir, ".cline")
if err := os.MkdirAll(clineDir, 0o755); err != nil {
t.Fatalf("mkdir clineDir: %v", err)
}
t.Setenv("CLINE_DIR", clineDir)
return clineDir
}
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
t.Helper()
bin := repoAwareBinPath(t)
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
// Prepend persistent flag so Cobra sees it regardless of subcommand position
args = append([]string{"--config", clineDir}, args...)
}
cmd := exec.CommandContext(ctx, bin, args...)
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
if wd, err := os.Getwd(); err == nil {
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
cmd.Dir = repoRoot
}
// propagate env including CLINE_DIR
cmd.Env = os.Environ()
outB, errB := &strings.Builder{}, &strings.Builder{}
cmd.Stdout = outB
cmd.Stderr = errB
err := cmd.Run()
exit := 0
if err != nil {
// Extract exit code if possible
if ee, ok := err.(*exec.ExitError); ok {
exit = ee.ExitCode()
} else {
exit = -1
}
}
return outB.String(), errB.String(), exit
}
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
t.Helper()
out, errOut, exit := runCLI(ctx, t, args...)
if exit != 0 {
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
}
return out
}
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
t.Helper()
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
_ = mustRunCLI(ctx, t, "instance", "list")
// Read from SQLite locks database to build structured output
clineDir := getClineDir(t)
// Load default instance from settings file
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
// Load instances from SQLite
instances := readInstancesFromSQLite(t, clineDir)
return common.InstancesOutput{
DefaultInstance: defaultInstance,
CoreInstances: instances,
}
}
func hasAddress(in common.InstancesOutput, addr string) bool {
for _, it := range in.CoreInstances {
if it.Address == addr {
return true
}
}
return false
}
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
for _, it := range in.CoreInstances {
if it.Address == addr {
return it, true
}
}
return common.CoreInstanceInfo{}, false
}
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
ok, msg := cond()
if ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("waitFor timeout: %s", msg)
}
time.Sleep(pollInterval)
}
}
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
t.Logf("Waiting for gRPC health check on %s...", addr)
waitFor(t, timeout, func() (bool, string) {
if common.IsInstanceHealthy(ctx, addr) {
return true, ""
}
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
})
t.Logf("gRPC health check passed for %s", addr)
}
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
waitFor(t, timeout, func() (bool, string) {
out := listInstancesJSON(ctx, t)
if hasAddress(out, addr) {
return false, fmt.Sprintf("address %s still present", addr)
}
return true, ""
})
}
func findFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen 127.0.0.1:0: %v", err)
}
defer l.Close()
_, portStr, _ := net.SplitHostPort(l.Addr().String())
var port int
fmt.Sscanf(portStr, "%d", &port)
return port
}
func getClineDir(t *testing.T) string {
t.Helper()
clineDir := os.Getenv("CLINE_DIR")
if clineDir == "" {
t.Fatalf("CLINE_DIR not set")
}
return clineDir
}
// isPortInUse checks if a port is currently in use by any process
func isPortInUse(port int) bool {
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
return true // Port is in use
}
conn.Close()
return false // Port is free
}
// waitForPortClosed waits for a port to become free (no process listening)
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
t.Helper()
waitFor(t, timeout, func() (bool, string) {
if isPortInUse(port) {
return false, fmt.Sprintf("port %d still in use", port)
}
return true, ""
})
}
// waitForPortsClosed waits for both core and host ports to become free
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
t.Helper()
waitFor(t, timeout, func() (bool, string) {
if isPortInUse(corePort) {
return false, fmt.Sprintf("core port %d still in use", corePort)
}
if isPortInUse(hostPort) {
return false, fmt.Sprintf("host port %d still in use", hostPort)
}
return true, ""
})
}
// findAndKillHostProcess finds and kills any process listening on the host port
// This is used to clean up dangling host processes after SIGKILL tests
func findAndKillHostProcess(t *testing.T, hostPort int) {
t.Helper()
// Use lsof to find process listening on the host port
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
output, err := cmd.Output()
if err != nil {
// No process found on port - that's fine
return
}
pidStr := strings.TrimSpace(string(output))
if pidStr == "" {
return
}
var pid int
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
return
}
if pid > 0 {
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
}
}
}
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
func getPIDByPort(t *testing.T, port int) int {
t.Helper()
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
output, err := cmd.Output()
if err != nil {
return 0 // Process not found
}
pidStr := strings.TrimSpace(string(output))
if pidStr == "" {
return 0
}
pid, err := strconv.Atoi(pidStr)
if err != nil {
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
return 0
}
return pid
}
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
func getCorePIDViaRPC(t *testing.T, address string) int {
t.Helper()
// Initialize global config to access registry
clineDir := os.Getenv("CLINE_DIR")
if clineDir == "" {
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
return getCorePIDViaLsof(t, address)
}
cfg := &global.GlobalConfig{
ConfigPath: clineDir,
}
if err := global.InitializeGlobalConfig(cfg); err != nil {
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
return getCorePIDViaLsof(t, address)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Get client for the address
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
if err != nil {
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
return getCorePIDViaLsof(t, address)
}
// Call GetProcessInfo RPC
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
return getCorePIDViaLsof(t, address)
}
return int(processInfo.ProcessId)
}
// getCorePIDViaLsof returns the PID using lsof (fallback method)
func getCorePIDViaLsof(t *testing.T, address string) int {
t.Helper()
_, portStr, err := net.SplitHostPort(address)
if err != nil {
t.Logf("Warning: invalid address format %s", address)
return 0
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Logf("Warning: invalid port in address %s", address)
return 0
}
return getPIDByPort(t, port)
}
// getCorePID returns the PID of the cline-core process for the given address
// Uses RPC first, falls back to lsof if RPC fails
func getCorePID(t *testing.T, address string) int {
t.Helper()
// Try RPC first (preferred method)
if pid := getCorePIDViaRPC(t, address); pid > 0 {
return pid
}
// Fall back to lsof if RPC fails
return getCorePIDViaLsof(t, address)
}
// getHostPID returns the PID of the cline-host process for the given host port
func getHostPID(t *testing.T, hostPort int) int {
t.Helper()
return getPIDByPort(t, hostPort)
}
// contains reports whether slice has the target string.
func contains(slice []string, target string) bool {
for _, s := range slice {
if s == target {
return true
}
}
return false
}
-47
View File
@@ -1,47 +0,0 @@
package e2e
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain validates required artifacts exist before running E2E tests.
// It does NOT build artifacts. Build manually via:
//
// npm run compile-standalone
// npm run compile-cli
func TestMain(m *testing.M) {
// Determine repo root from cli/e2e
wd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
os.Exit(2)
}
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
cliBin := filepath.Join(repoRoot, "cli", "bin", "cline")
coreJS := filepath.Join(repoRoot, "dist-standalone", "cline-core.js")
missing := []string{}
if _, err := os.Stat(cliBin); err != nil {
missing = append(missing, cliBin)
}
if _, err := os.Stat(coreJS); err != nil {
missing = append(missing, coreJS)
}
if len(missing) > 0 {
if testing.Short() {
// Optional quality-of-life: allow skipping with -short when artifacts are absent
fmt.Fprintf(os.Stderr, "[e2e] skipping (-short) due to missing artifacts:\n %s\n", strings.Join(missing, "\n "))
os.Exit(0)
}
fmt.Fprintf(os.Stderr, "Missing required build artifacts for E2E tests:\n %s\n\nPlease build them first:\n npm run compile-standalone\n npm run compile-cli\n", strings.Join(missing, "\n "))
os.Exit(2)
}
os.Exit(m.Run())
}
-120
View File
@@ -1,120 +0,0 @@
package e2e
import (
"context"
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/cline/cli/pkg/common"
)
// 9. Mixed localhost vs 127.0.0.1 addresses coexist and are both healthy
func TestMixedLocalhostVs127Coexist(t *testing.T) {
clineDir := setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start one instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Get the running instance and its port/PID
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) == 0 {
t.Fatalf("expected at least 1 instance")
}
inst := out.CoreInstances[0]
waitForAddressHealthy(t, inst.Address, defaultTimeout)
// Manually add a SQLite entry for the same port but 127.0.0.1 host
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
if err := insertRemoteInstanceIntoSQLite(t, dbPath, addr127, inst.CorePort(), inst.HostPort()); err != nil {
t.Fatalf("insert 127 alias entry: %v", err)
}
// Verify both addresses appear and are healthy
waitForAddressHealthy(t, inst.Address, defaultTimeout)
waitForAddressHealthy(t, addr127, defaultTimeout)
out = listInstancesJSON(ctx, t)
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
}
}
// 10. Start-stop stress: loop starting then killing instances; ensure no leftovers
func TestStartStopStress(t *testing.T) {
_ = setTempClineDir(t)
for i := 0; i < 3; i++ { // keep small for CI time
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Snapshot current addresses
before := listInstancesJSON(ctx, t)
beforeSet := map[string]struct{}{}
for _, it := range before.CoreInstances {
beforeSet[it.Address] = struct{}{}
}
// Start a new instance
_ = mustRunCLI(ctx, t, "instance", "new")
// Find the new instance address
var newAddr string
waitFor(t, defaultTimeout, func() (bool, string) {
after := listInstancesJSON(ctx, t)
for _, it := range after.CoreInstances {
if _, ok := beforeSet[it.Address]; !ok {
newAddr = it.Address
return true, ""
}
}
return false, "new instance address not detected yet"
})
// Wait healthy
waitForAddressHealthy(t, newAddr, defaultTimeout)
// Get PID using runtime discovery and kill it
after := listInstancesJSON(ctx, t)
info, ok := getByAddress(after, newAddr)
if !ok {
t.Fatalf("new instance %s missing", newAddr)
}
// Get PID using runtime discovery
corePID := getCorePID(t, info.Address)
if corePID <= 0 {
t.Fatalf("could not find PID for new instance at %s", info.Address)
}
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
t.Fatalf("kill pid %d: %v", corePID, err)
}
// Wait removed from SQLite database
waitForAddressRemoved(t, newAddr, longTimeout)
// Verify instance is removed from SQLite database
clineDir := os.Getenv("CLINE_DIR")
if clineDir != "" {
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
if verifyInstanceExistsInSQLite(t, dbPath, newAddr) {
t.Fatalf("expected instance removed from SQLite database: %s", newAddr)
}
}
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process on port %d for iteration %d", info.HostPort(), i)
findAndKillHostProcess(t, info.HostPort())
// Verify both ports are now free
waitForPortsClosed(t, info.CorePort(), info.HostPort(), defaultTimeout)
}
}
-161
View File
@@ -1,161 +0,0 @@
package e2e
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/cline/cli/pkg/common"
_ "github.com/glebarez/go-sqlite"
"google.golang.org/grpc/health/grpc_health_v1"
)
// readInstancesFromSQLite reads instances directly from the SQLite database for testing
func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanceInfo {
t.Helper()
dbPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "locks.db")
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return []common.CoreInstanceInfo{}
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Logf("Warning: Failed to open SQLite database: %v", err)
return []common.CoreInstanceInfo{}
}
defer db.Close()
// Query instance locks
query := common.SelectInstanceLockHoldersAscSQL
rows, err := db.Query(query)
if err != nil {
t.Logf("Warning: Failed to query instance locks: %v", err)
return []common.CoreInstanceInfo{}
}
defer rows.Close()
var instances []common.CoreInstanceInfo
for rows.Next() {
var heldBy, lockTarget string
var lockedAt int64
err := rows.Scan(&heldBy, &lockTarget, &lockedAt)
if err != nil {
t.Logf("Warning: Failed to scan lock row: %v", err)
continue
}
// Create InstanceInfo
info := common.CoreInstanceInfo{
Address: heldBy,
HostServiceAddress: lockTarget,
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
}
instances = append(instances, info)
}
return instances
}
// readDefaultInstanceFromSettings reads the default instance from the settings file
func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string {
t.Helper()
settingsPath := filepath.Join(clineDir, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
data, err := os.ReadFile(settingsPath)
if err != nil {
if os.IsNotExist(err) {
return ""
}
t.Logf("Warning: Failed to read default instance file: %v", err)
return ""
}
var tmp struct {
DefaultInstance string `json:"default_instance"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
t.Logf("Warning: Failed to parse default instance file: %v", err)
return ""
}
return tmp.DefaultInstance
}
// insertRemoteInstanceIntoSQLite inserts a remote instance entry directly into SQLite for testing
func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return err
}
defer db.Close()
// Initialize database schema for testing
createTableSQL := `
CREATE TABLE IF NOT EXISTS locks (
id INTEGER PRIMARY KEY,
held_by TEXT NOT NULL,
lock_type TEXT NOT NULL CHECK (lock_type IN ('file', 'instance', 'folder')),
lock_target TEXT NOT NULL,
locked_at INTEGER NOT NULL,
UNIQUE(lock_type, lock_target)
);
`
createIndexesSQL := `
CREATE INDEX IF NOT EXISTS idx_locks_held_by ON locks(held_by);
CREATE INDEX IF NOT EXISTS idx_locks_type ON locks(lock_type);
CREATE INDEX IF NOT EXISTS idx_locks_target ON locks(lock_target);
`
if _, err := db.Exec(createTableSQL); err != nil {
return err
}
if _, err := db.Exec(createIndexesSQL); err != nil {
return err
}
// Insert the remote instance
hostAddress := "remote.example.com:0"
if hostPort != 0 {
hostAddress = "remote.example.com:" + strconv.Itoa(hostPort)
}
insertSQL := `INSERT INTO locks (held_by, lock_type, lock_target, locked_at) VALUES (?, 'instance', ?, ?)`
_, err = db.Exec(insertSQL, address, hostAddress, time.Now().Unix()*1000)
return err
}
// verifyInstanceExistsInSQLite checks if an instance exists in the SQLite database
func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Logf("Failed to open database: %v", err)
return false
}
defer db.Close()
query := `SELECT COUNT(*) FROM locks WHERE held_by = ? AND lock_type = 'instance'`
var count int
err = db.QueryRow(query, address).Scan(&count)
if err != nil {
t.Logf("Failed to query database: %v", err)
return false
}
return count > 0
}
-178
View File
@@ -1,178 +0,0 @@
package e2e
import (
"context"
"fmt"
"syscall"
"testing"
)
// TestStartAndList verifies self-registration and default.json semantics in a fresh CLINE_DIR.
func TestStartAndList(t *testing.T) {
clineDir := setTempClineDir(t)
t.Logf("Using temp CLINE_DIR: %s", clineDir)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
t.Logf("Starting new instance...")
// Start a new instance
startOutput := mustRunCLI(ctx, t, "instance", "new")
t.Logf("Instance start output: %s", startOutput)
t.Logf("Listing instances to check registration...")
// It should appear healthy in list JSON and be the default.
out := listInstancesJSON(ctx, t)
t.Logf("Found %d instances after start", len(out.CoreInstances))
if len(out.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
addr := out.CoreInstances[0].Address
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
t.Logf("Waiting for address %s to become healthy...", addr)
waitForAddressHealthy(t, addr, defaultTimeout)
t.Logf("Address %s is now healthy", addr)
t.Logf("Checking default instance configuration...")
// Default should be set to the new instance.
out = listInstancesJSON(ctx, t)
t.Logf("Default instance: %s", out.DefaultInstance)
if out.DefaultInstance == "" {
t.Fatalf("default_instance not set")
}
if out.DefaultInstance != out.CoreInstances[0].Address {
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
}
t.Logf("TestStartAndList completed successfully")
}
// TestTaskNewDefault ensures tasks route to default instance.
func TestTaskNewDefault(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start one instance and wait for healthy
_ = mustRunCLI(ctx, t, "instance", "new")
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) != 1 {
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
}
addr := out.CoreInstances[0].Address
waitForAddressHealthy(t, addr, defaultTimeout)
// Create a new task at default (success is sufficient)
_ = mustRunCLI(ctx, t, "task", "new", "hello world")
}
// TestExplicitAddressAutoStart verifies that giving an explicit address auto-starts an instance and routes the task.
func TestExplicitAddressAutoStart(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Find a free port and use explicit address. This should auto-start an instance.
port := findFreePort(t)
addr := "localhost:" + itoa(port)
// Run a task at explicit address (auto-start path)
_ = mustRunCLI(ctx, t, "task", "new", "--address", "localhost:"+itoa(port), "explicit address task")
// Verify the instance is present and healthy
waitForAddressHealthy(t, addr, defaultTimeout)
}
// TestCrashCleanup verifies that after SIGKILL of a local core, the cleanup removes the registry entry.
// Also tests graceful shutdown (SIGTERM) vs crash cleanup and ensures no dangling host processes.
func TestCrashCleanup(t *testing.T) {
_ = setTempClineDir(t)
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
defer cancel()
// Start two instances for testing both graceful and crash scenarios
_ = mustRunCLI(ctx, t, "instance", "new")
_ = mustRunCLI(ctx, t, "instance", "new")
out := listInstancesJSON(ctx, t)
if len(out.CoreInstances) < 2 {
t.Fatalf("expected at least 2 instances, got %d", len(out.CoreInstances))
}
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
gracefulTarget := out.CoreInstances[0]
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
// Get PID using runtime discovery
gracefulPID := getCorePID(t, gracefulTarget.Address)
if gracefulPID <= 0 {
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
}
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
}
// Wait for registry cleanup
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
// Verify both core and host ports are freed (no dangling processes)
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
// Verify the instance is removed from SQLite (no file to check anymore)
// The waitForAddressRemoved already confirms the instance is gone from the registry
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
crashTarget := out.CoreInstances[1]
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
// Get PID using runtime discovery
crashPID := getCorePID(t, crashTarget.Address)
if crashPID <= 0 {
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
}
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
}
// Wait for registry cleanup
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
// Verify the instance is removed from SQLite (no file to check anymore)
// The waitForAddressRemoved already confirms the instance is gone from the registry
// Clean up dangling host process (SIGKILL leaves these behind by design)
t.Logf("Cleaning up dangling host process %s", crashTarget.HostServiceAddress)
findAndKillHostProcess(t, crashTarget.HostPort())
// Verify both ports are now free
waitForPortsClosed(t, crashTarget.CorePort(), crashTarget.HostPort(), defaultTimeout)
}
// itoa is a small helper for readability
func itoa(i int) string {
return strconvItoa(i)
}
// minimal inline int->string to avoid extra imports in helpers
func strconvItoa(i int) string {
// simple fast path
return fmtInt(i)
}
func fmtInt(i int) string {
// allocate small buffer; ints here are short
return (func(n int) string {
return fmt.Sprintf("%d", n)
})(i)
}
-64
View File
@@ -1,64 +0,0 @@
module github.com/cline/cli
go 1.23.0
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/cline/grpc-go v0.0.0
github.com/glebarez/go-sqlite v1.22.0
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.8.0
golang.org/x/term v0.32.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
)
replace github.com/cline/grpc-go => ../src/generated/grpc-go
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)
-162
View File
@@ -1,162 +0,0 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532 h1:+xmbw70JXxmsOqvm1PEIAqFnqI/Hy2RYqrK7CtPmsNY=
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
-331
View File
@@ -1,331 +0,0 @@
.\" Automatically generated by Pandoc 3.8.2
.\"
.TH "CLINE" "1" "January 2025" "Cline CLI 1.0" "User Commands"
.SH NAME
cline \- orchestrate and interact with Cline AI coding agents
.SH SYNOPSIS
\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]]
.PP
\f[B]cline\f[R] \f[I]command\f[R] [\f[I]subcommand\f[R]]
[\f[I]options\f[R]] [\f[I]arguments\f[R]]
.SH DESCRIPTION
Try: cat README.md | cline \(lqSummarize this for me:\(rq
.PP
\f[B]cline\f[R] is a command\-line interface for orchestrating multiple
Cline AI coding agents.
Cline is an autonomous AI agent who can read, write, and execute code
across your projects.
He operates through a client\-server architecture where \f[B]Cline
Core\f[R] runs as a standalone service, and the CLI acts as a scriptable
interface for managing tasks, instances, and agent interactions.
.PP
The CLI is designed for both interactive use and automation, making it
ideal for CI/CD pipelines, parallel task execution, and terminal\-based
workflows.
Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline
Core instance, enabling seamless task handoff between environments.
.SH MODES OF OPERATION
.TP
\f[B]Instant Task Mode\f[R]
The simplest invocation: \f[B]cline \(lqprompt here\(rq\f[R] immediately
spawns an instance, creates a task, and enters chat mode.
This is equivalent to running \f[B]cline instance new && cline task new
&& cline task chat\f[R] in sequence.
.TP
\f[B]Subcommand Mode\f[R]
Advanced usage with explicit control: \f[B]cline <command> [subcommand]
[options]\f[R] provides fine\-grained control over instances, tasks,
authentication, and configuration.
.SH AGENT BEHAVIOR
Cline operates in two primary modes:
.TP
\f[B]ACT MODE\f[R]
Cline actively uses tools to accomplish tasks.
He can read files, write code, execute commands, use a headless browser,
and more.
This is the default mode for task execution.
.TP
\f[B]PLAN MODE\f[R]
Cline gathers information and creates a detailed plan before
implementation.
He explores the codebase, asks clarifying questions, and presents a
strategy for user approval before switching to ACT MODE.
.SH INSTANT TASK OPTIONS
When using the instant task syntax \f[B]cline \(lqprompt\(rq\f[R] the
following options are available:
.TP
\f[B]\-o\f[R], \f[B]\-\-oneshot\f[R]
Full autonomous mode.
Cline completes the task and stops following after completion.
Example: cline \-o \(lqwhat\(cqs 6 + 8?\(rq
.TP
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
Override a setting for this task
.TP
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
Enable fully autonomous mode.
Disables all interactivity:
.RS
.IP \(bu 2
ask_followup_question tool is disabled
.IP \(bu 2
attempt_completion happens automatically
.IP \(bu 2
execute_command runs in non\-blocking mode with timeout
.IP \(bu 2
PLAN MODE automatically switches to ACT MODE
.RE
.TP
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
Starting mode.
Options: \f[B]act\f[R] (default), \f[B]plan\f[R]
.SH GLOBAL OPTIONS
These options apply to all subcommands:
.TP
\f[B]\-F\f[R], \f[B]\-\-output\-format\f[R] \f[I]format\f[R]
Output format.
Options: \f[B]rich\f[R] (default), \f[B]json\f[R], \f[B]plain\f[R]
.TP
\f[B]\-h\f[R], \f[B]\-\-help\f[R]
Display help information for the command.
.TP
\f[B]\-v\f[R], \f[B]\-\-verbose\f[R]
Enable verbose output for debugging.
.SH COMMANDS
.SS Authentication
\f[B]cline auth\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
.TP
\f[B]cline a\f[R] [\f[I]provider\f[R]] [\f[I]key\f[R]]
Configure authentication for AI model providers.
Launches an interactive wizard if no arguments provided.
If provider is specified without a key, prompts for the key or launches
the appropriate OAuth flow.
.SS Instance Management
Cline Core instances are independent agent processes that can run in the
background.
Multiple instances can run simultaneously, enabling parallel task
execution.
.PP
\f[B]cline instance\f[R]
.TP
\f[B]cline i\f[R]
Display instance management help.
.PP
\f[B]cline instance new\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
.TP
\f[B]cline i n\f[R] [\f[B]\-d\f[R]|\f[B]\-\-default\f[R]]
Spawn a new Cline Core instance.
Use \f[B]\-\-default\f[R] to set it as the default instance for
subsequent commands.
.PP
\f[B]cline instance list\f[R]
.TP
\f[B]cline i l\f[R]
List all running Cline Core instances with their addresses and status.
.PP
\f[B]cline instance default\f[R] \f[I]address\f[R]
.TP
\f[B]cline i d\f[R] \f[I]address\f[R]
Set the default instance to avoid specifying \f[B]\-\-address\f[R] in
task commands.
.PP
\f[B]cline instance kill\f[R] \f[I]address\f[R]
[\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
.TP
\f[B]cline i k\f[R] \f[I]address\f[R] [\f[B]\-a\f[R]|\f[B]\-\-all\f[R]]
Terminate a Cline Core instance.
Use \f[B]\-\-all\f[R] to kill all running instances.
.SS Task Management
Tasks represent individual work items that Cline executes.
Tasks maintain conversation history, checkpoints, and settings.
.PP
\f[B]cline task\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R]
\f[I]ADDR\f[R]]
.TP
\f[B]cline t\f[R] [\f[B]\-a\f[R]|\f[B]\-\-address\f[R] \f[I]ADDR\f[R]]
Display task management help.
The \f[B]\-\-address\f[R] flag specifies which Cline Core instance to
use (e.g., localhost:50052).
.PP
\f[B]cline task new\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
.TP
\f[B]cline t n\f[R] \f[I]prompt\f[R] [\f[I]options\f[R]]
Create a new task in the default or specified instance.
Options:
.RS
.TP
\f[B]\-s\f[R], \f[B]\-\-setting\f[R] \f[I]setting\f[R] \f[I]value\f[R]
Set task\-specific settings
.TP
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
Enable autonomous mode
.TP
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
Starting mode (act or plan)
.RE
.PP
\f[B]cline task open\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
.TP
\f[B]cline t o\f[R] \f[I]task\-id\f[R] [\f[I]options\f[R]]
Resume a previous task from history.
Accepts the same options as \f[B]task new\f[R].
.PP
\f[B]cline task list\f[R]
.TP
\f[B]cline t l\f[R]
List all tasks in history with their id and snippet
.PP
\f[B]cline task chat\f[R]
.TP
\f[B]cline t c\f[R]
Enter interactive chat mode for the current task.
Allows back\-and\-forth conversation with Cline.
.PP
\f[B]cline task send\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
.TP
\f[B]cline t s\f[R] [\f[I]message\f[R]] [\f[I]options\f[R]]
Send a message to Cline.
If no message is provided, reads from stdin.
Options:
.RS
.TP
\f[B]\-a\f[R], \f[B]\-\-approve\f[R]
Approve Cline\(cqs proposed action
.TP
\f[B]\-d\f[R], \f[B]\-\-deny\f[R]
Deny Cline\(cqs proposed action
.TP
\f[B]\-f\f[R], \f[B]\-\-file\f[R] \f[I]FILE\f[R]
Attach a file to the message
.TP
\f[B]\-y\f[R], \f[B]\-\-no\-interactive\f[R], \f[B]\-\-yolo\f[R]
Enable autonomous mode
.TP
\f[B]\-m\f[R], \f[B]\-\-mode\f[R] \f[I]mode\f[R]
Switch mode (act or plan)
.RE
.PP
\f[B]cline task view\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]]
[\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
.TP
\f[B]cline t v\f[R] [\f[B]\-f\f[R]|\f[B]\-\-follow\f[R]] [\f[B]\-c\f[R]|\f[B]\-\-follow\-complete\f[R]]
Display the current conversation.
Use \f[B]\-\-follow\f[R] to stream updates in real\-time, or
\f[B]\-\-follow\-complete\f[R] to follow until task completion.
.PP
\f[B]cline task restore\f[R] \f[I]checkpoint\f[R]
.TP
\f[B]cline t r\f[R] \f[I]checkpoint\f[R]
Restore the task to a previous checkpoint state.
.PP
\f[B]cline task pause\f[R]
.TP
\f[B]cline t p\f[R]
Pause task execution.
.SS Configuration
Configuration can be set globally.
Override these global settings for a task using the
\f[B]\-\-setting\f[R] flag
.PP
\f[B]cline config\f[R]
.PP
\f[B]cline c\f[R]
.PP
\f[B]cline config set\f[R] \f[I]key\f[R] \f[I]value\f[R]
.TP
\f[B]cline c s\f[R] \f[I]key\f[R] \f[I]value\f[R]
Set a configuration variable.
.PP
\f[B]cline config get\f[R] \f[I]key\f[R]
.TP
\f[B]cline c g\f[R] \f[I]key\f[R]
Read a configuration variable.
.PP
\f[B]cline config list\f[R]
.TP
\f[B]cline c l\f[R]
List all configuration variables and their values.
.SH TASK SETTINGS
Task settings are persisted in the \f[I]\(ti/.cline/x/tasks\f[R]
directory.
When resuming a task with \f[B]cline task open\f[R], task settings are
automatically restored.
.PP
Common settings include:
.TP
\f[B]yolo\f[R]
Enable autonomous mode (true/false)
.TP
\f[B]mode\f[R]
Starting mode (act/plan)
.SH NOTES & EXAMPLES
The \f[B]cline task send\f[R] and \f[B]cline task new\f[R] commands
support reading from stdin, enabling powerful pipeline compositions:
.IP
.EX
cat requirements.txt \f[B]|\f[R] cline task send
echo \(dqRefactor this code\(dq \f[B]|\f[R] cline \-y
.EE
.SS Instance Management
Manage multiple Cline instances:
.IP
.EX
\f[I]# Start a new instance and make it default\f[R]
cline instance new \-\-default
\f[I]# List all running instances\f[R]
cline instance list
\f[I]# Kill a specific instance\f[R]
cline instance kill localhost:50052
\f[I]# Kill all CLI instances\f[R]
cline instance kill \-\-all\-cli
.EE
.SS Task History
Work with task history:
.IP
.EX
\f[I]# List previous tasks\f[R]
cline task list
\f[I]# Resume a previous task\f[R]
cline task open 1760501486669
\f[I]# View conversation history\f[R]
cline task view
\f[I]# Start interactive chat with this task\f[R]
cline task chat
.EE
.SH ARCHITECTURE
Cline operates on a three\-layer architecture:
.TP
\f[B]Presentation Layer\f[R]
User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via
gRPC
.TP
\f[B]Cline Core\f[R]
The autonomous agent service handling task management, AI model
integration, state management, tool orchestration, and real\-time
streaming updates
.TP
\f[B]Host Provider Layer\f[R]
Environment\-specific integrations (VSCode APIs, JetBrains APIs, shell
APIs) that Cline Core uses to interact with the host system
.SH BUGS
Report bugs at: \c
.UR https://github.com/cline/cline/issues
.UE \c
.PP
For real\-time help, join the Discord community at: \c
.UR https://discord.gg/cline
.UE \c
.SH SEE ALSO
Full documentation: \c
.UR https://docs.cline.bot
.UE \c
.SH AUTHORS
Cline is developed by the Cline Bot Inc.\ and the open source community.
.SH COPYRIGHT
Copyright © 2025 Cline Bot Inc.\ Licensed under the Apache License 2.0.
-332
View File
@@ -1,332 +0,0 @@
---
title: CLINE
section: 1
header: User Commands
footer: Cline CLI 1.0
date: January 2025
---
# NAME
cline - orchestrate and interact with Cline AI coding agents
# SYNOPSIS
**cline** [*prompt*] [*options*]
**cline** *command* [*subcommand*] [*options*] [*arguments*]
# DESCRIPTION
Try: cat README.md | cline "Summarize this for me:"
**cline** is a command-line interface for orchestrating multiple Cline AI coding agents. Cline is an autonomous AI agent who can read, write, and execute code across your projects. He operates through a client-server architecture where **Cline Core** runs as a standalone service, and the CLI acts as a scriptable interface for managing tasks, instances, and agent interactions.
The CLI is designed for both interactive use and automation, making it ideal for CI/CD pipelines, parallel task execution, and terminal-based workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline Core instance, enabling seamless task handoff between environments.
# MODES OF OPERATION
**Instant Task Mode**
: The simplest invocation: **cline "prompt here"** immediately spawns an instance, creates a task, and enters chat mode. This is equivalent to running **cline instance new && cline task new && cline task chat** in sequence.
**Subcommand Mode**
: Advanced usage with explicit control: **cline \<command\> [subcommand] [options]** provides fine-grained control over instances, tasks, authentication, and configuration.
# AGENT BEHAVIOR
Cline operates in two primary modes:
**ACT MODE**
: Cline actively uses tools to accomplish tasks. He can read files, write code, execute commands, use a headless browser, and more. This is the default mode for task execution.
**PLAN MODE**
: Cline gathers information and creates a detailed plan before implementation. He explores the codebase, asks clarifying questions, and presents a strategy for user approval before switching to ACT MODE.
# INSTANT TASK OPTIONS
When using the instant task syntax **cline "prompt"** the following options are available:
**-o**, **\--oneshot**
: Full autonomous mode. Cline completes the task and stops following after completion. Example: cline -o "what's 6 + 8?"
**-s**, **\--setting** *setting* *value*
: Override a setting for this task
**-y**, **\--no-interactive**, **\--yolo**
: Enable fully autonomous mode. Disables all interactivity:
- ask_followup_question tool is disabled
- attempt_completion happens automatically
- execute_command runs in non-blocking mode with timeout
- PLAN MODE automatically switches to ACT MODE
**-m**, **\--mode** *mode*
: Starting mode. Options: **act** (default), **plan**
# GLOBAL OPTIONS
These options apply to all subcommands:
**-F**, **\--output-format** *format*
: Output format. Options: **rich** (default), **json**, **plain**
**-h**, **\--help**
: Display help information for the command.
**-v**, **\--verbose**
: Enable verbose output for debugging.
# COMMANDS
## Authentication
**cline auth** [*provider*] [*key*]
**cline a** [*provider*] [*key*]
: Configure authentication for AI model providers. Launches an interactive wizard if no arguments provided. If provider is specified without a key, prompts for the key or launches the appropriate OAuth flow.
## Instance Management
Cline Core instances are independent agent processes that can run in the background. Multiple instances can run simultaneously, enabling parallel task execution.
**cline instance**
**cline i**
: Display instance management help.
**cline instance new** [**-d**|**\--default**]
**cline i n** [**-d**|**\--default**]
: Spawn a new Cline Core instance. Use **\--default** to set it as the default instance for subsequent commands.
**cline instance list**
**cline i l**
: List all running Cline Core instances with their addresses and status.
**cline instance default** *address*
**cline i d** *address*
: Set the default instance to avoid specifying **\--address** in task commands.
**cline instance kill** *address* [**-a**|**\--all**]
**cline i k** *address* [**-a**|**\--all**]
: Terminate a Cline Core instance. Use **\--all** to kill all running instances.
## Task Management
Tasks represent individual work items that Cline executes. Tasks maintain conversation history, checkpoints, and settings.
**cline task** [**-a**|**\--address** *ADDR*]
**cline t** [**-a**|**\--address** *ADDR*]
: Display task management help. The **\--address** flag specifies which Cline Core instance to use (e.g., localhost:50052).
**cline task new** *prompt* [*options*]
**cline t n** *prompt* [*options*]
: Create a new task in the default or specified instance. Options:
**-s**, **\--setting** *setting* *value*
: Set task-specific settings
**-y**, **\--no-interactive**, **\--yolo**
: Enable autonomous mode
**-m**, **\--mode** *mode*
: Starting mode (act or plan)
**cline task open** *task-id* [*options*]
**cline t o** *task-id* [*options*]
: Resume a previous task from history. Accepts the same options as **task new**.
**cline task list**
**cline t l**
: List all tasks in history with their id and snippet
**cline task chat**
**cline t c**
: Enter interactive chat mode for the current task. Allows back-and-forth conversation with Cline.
**cline task send** [*message*] [*options*]
**cline t s** [*message*] [*options*]
: Send a message to Cline. If no message is provided, reads from stdin. Options:
**-a**, **\--approve**
: Approve Cline's proposed action
**-d**, **\--deny**
: Deny Cline's proposed action
**-f**, **\--file** *FILE*
: Attach a file to the message
**-y**, **\--no-interactive**, **\--yolo**
: Enable autonomous mode
**-m**, **\--mode** *mode*
: Switch mode (act or plan)
**cline task view** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
**cline t v** [**-f**|**\--follow**] [**-c**|**\--follow-complete**]
: Display the current conversation. Use **\--follow** to stream updates in real-time, or **\--follow-complete** to follow until task completion.
**cline task restore** *checkpoint*
**cline t r** *checkpoint*
: Restore the task to a previous checkpoint state.
**cline task pause**
**cline t p**
: Pause task execution.
## Configuration
Configuration can be set globally. Override these global settings for a task using the **\--setting** flag
**cline config**
**cline c**
**cline config set** *key* *value*
**cline c s** *key* *value*
: Set a configuration variable.
**cline config get** *key*
**cline c g** *key*
: Read a configuration variable.
**cline config list**
**cline c l**
: List all configuration variables and their values.
# TASK SETTINGS
Task settings are persisted in the *~/.cline/x/tasks* directory. When resuming a task with **cline task open**, task settings are automatically restored.
Common settings include:
**yolo**
: Enable autonomous mode (true/false)
**mode**
: Starting mode (act/plan)
# NOTES & EXAMPLES
The **cline task send** and **cline task new** commands support reading from stdin, enabling powerful pipeline compositions:
```bash
cat requirements.txt | cline task send
echo "Refactor this code" | cline -y
```
## Instance Management
Manage multiple Cline instances:
```bash
# Start a new instance and make it default
cline instance new --default
# List all running instances
cline instance list
# Kill a specific instance
cline instance kill localhost:50052
# Kill all CLI instances
cline instance kill --all-cli
```
## Task History
Work with task history:
```bash
# List previous tasks
cline task list
# Resume a previous task
cline task open 1760501486669
# View conversation history
cline task view
# Start interactive chat with this task
cline task chat
```
# ARCHITECTURE
Cline operates on a three-layer architecture:
**Presentation Layer**
: User interfaces (CLI, VSCode, JetBrains) that connect to Cline Core via gRPC
**Cline Core**
: The autonomous agent service handling task management, AI model integration, state management, tool orchestration, and real-time streaming updates
**Host Provider Layer**
: Environment-specific integrations (VSCode APIs, JetBrains APIs, shell APIs) that Cline Core uses to interact with the host system
# BUGS
Report bugs at: <https://github.com/cline/cline/issues>
For real-time help, join the Discord community at: <https://discord.gg/cline>
# SEE ALSO
Full documentation: <https://docs.cline.bot>
# AUTHORS
Cline is developed by the Cline Bot Inc. and the open source community.
# COPYRIGHT
Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0.
-68
View File
@@ -1,68 +0,0 @@
{
"name": "cline",
"version": "1.0.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": [
"darwin",
"linux"
],
"cpu": [
"x64",
"arm64"
]
}
-43
View File
@@ -1,43 +0,0 @@
package cli
import (
"github.com/cline/cli/pkg/cli/auth"
"github.com/spf13/cobra"
)
func NewAuthCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate a provider and configure what model is used",
Long: `Authenticate a provider and configure what model is used
Interactive Mode:
Run without flags to open an interactive menu where you can:
- Sign in to your Cline account
- Configure other LLM providers (Anthropic, OpenAI, etc.)
- Select and switch between AI models
- Manage provider settings
Quick Setup Mode:
Use flags to quickly configure a BYO provider non-interactively:
Examples:
cline auth --provider openai-native --apikey sk-xxx --modelid gpt-5
cline auth -p anthropic -k sk-ant-xxx -m claude-sonnet-4-5-20250929
cline auth -p openai-compatible -k xxx -m gpt-4 -b https://api.example.com/v1
Supported providers: openai-native, openai, anthropic, gemini, openrouter, xai, cerebras, ollama
Note: Bedrock provider requires interactive setup due to complex auth fields`,
RunE: func(cmd *cobra.Command, args []string) error {
return auth.RunAuthFlow(cmd.Context(), args)
},
}
// Add flags for quick setup mode
cmd.Flags().StringVarP(&auth.QuickProvider, "provider", "p", "", "Provider ID for quick setup (e.g., openai-native, anthropic)")
cmd.Flags().StringVarP(&auth.QuickAPIKey, "apikey", "k", "", "API key for the provider")
cmd.Flags().StringVarP(&auth.QuickModelID, "modelid", "m", "", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929)")
cmd.Flags().StringVarP(&auth.QuickBaseURL, "baseurl", "b", "", "Base URL (optional, only for openai provider)")
return cmd
}
-285
View File
@@ -1,285 +0,0 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
var isSessionAuthenticated bool
// Cline provider specific code
func HandleClineAuth(ctx context.Context) error {
verboseLog("Authenticating with Cline...")
// Check if already authenticated
if IsAuthenticated(ctx) {
return signOutDialog(ctx)
}
// Perform sign in
if err := signIn(ctx); err != nil {
return err
}
fmt.Println()
verboseLog("✓ You are signed in!")
// Configure default Cline model after successful authentication
if err := configureDefaultClineModel(ctx); err != nil {
fmt.Printf("Warning: Could not configure default Cline model: %v\n", err)
fmt.Println("You can configure a model later with 'cline auth' and selecting 'Change Cline model'")
}
// Return to main auth menu after successful authentication
return HandleAuthMenuNoArgs(ctx)
}
func signOut(ctx context.Context) error {
client, err := global.GetDefaultClient(ctx)
if err != nil {
return err
}
if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil {
return err
}
isSessionAuthenticated = false
fmt.Println("You have been signed out of Cline.")
return nil
}
func signOutDialog(ctx context.Context) error {
var confirm bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("You are already signed in to Cline.").
Description("Would you like to sign out?").
Value(&confirm),
),
)
if err := form.Run(); err != nil {
return nil
}
if confirm {
if err := signOut(ctx); err != nil {
fmt.Printf("Failed to sign out: %v\n", err)
return err
}
}
return HandleAuthMenuNoArgs(ctx)
}
func signIn(ctx context.Context) error {
if IsAuthenticated(ctx) {
return nil
}
// Subscribe to auth updates before initiating login
verboseLog("Subscribing to auth status updates...")
listener, err := NewAuthStatusListener(ctx)
if err != nil {
verboseLog("Failed to subscribe to auth updates: %v", err)
return fmt.Errorf("failed to subscribe to auth updates: %w", err)
}
defer listener.Stop()
if err := listener.Start(); err != nil {
verboseLog("Failed to start auth listener: %v", err)
return fmt.Errorf("failed to start auth listener: %w", err)
}
// Initiate login (opens browser with callback URL from cline-core's AuthHandler)
verboseLog("Initiating login...")
client, err := global.GetDefaultClient(ctx)
if err != nil {
verboseLog("Failed to obtain client: %v", err)
return fmt.Errorf("failed to obtain client: %w", err)
}
response, err := client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{})
if err != nil {
verboseLog("Failed to initiate login: %v", err)
return fmt.Errorf("failed to initiate login: %w", err)
}
fmt.Println("\n Opening browser for authentication...")
if response != nil && response.Value != "" {
fmt.Printf(" If the browser doesn't open automatically, visit this URL:\n %s\n\n", response.Value)
}
fmt.Println(" Waiting for you to complete authentication in your browser...")
fmt.Println(" (This may take a few moments. Timeout: 5 minutes)")
// Wait for auth status update confirming success
verboseLog("Waiting for authentication to complete...")
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
verboseLog("Authentication failed or timed out: %v", err)
fmt.Println("\n Authentication failed or timed out.")
fmt.Println(" Please try again with 'cline auth'")
return err
}
// Only NOW set the session flag after confirmed authentication
isSessionAuthenticated = true
verboseLog("Login successful")
return nil
}
func IsAuthenticated(ctx context.Context) bool {
if isSessionAuthenticated {
verboseLog("Session is already authenticated")
return true
}
verboseLog("Verifying authentication with server...")
client, err := global.GetDefaultClient(ctx)
if err != nil {
verboseLog("Failed to get client for auth check: %v", err)
return false
}
_, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{})
if err == nil {
// Update session variable for future fast-path checks
verboseLog("Server verification successful, updating session flag")
isSessionAuthenticated = true
return true
}
verboseLog("Server verification failed: %v", err)
return false
}
// HandleChangeClineModel allows Cline-authenticated users to change their Cline model selection. Hidden when not authenticated.
func HandleChangeClineModel(ctx context.Context) error {
// Ensure user is authenticated
if !IsAuthenticated(ctx) {
return fmt.Errorf("you must be authenticated with Cline to change models. Run 'cline auth' to sign in")
}
// Get task manager
manager, err := createTaskManager(ctx)
if err != nil {
return fmt.Errorf("failed to create task manager: %w", err)
}
// Launch Cline model selection
return SelectClineModel(ctx, manager)
}
// configureDefaultClineModel configures the default Cline model after authentication
func configureDefaultClineModel(ctx context.Context) error {
verboseLog("Configuring default Cline model...")
// Create task manager
manager, err := task.NewManagerForDefault(ctx)
if err != nil {
return fmt.Errorf("failed to create task manager: %w", err)
}
// Set default Cline model
return SetDefaultClineModel(ctx, manager)
}
// HandleSelectOrganization allows Cline-authenticated users to select which organization to use
func HandleSelectOrganization(ctx context.Context) error {
// Ensure user is authenticated
if !IsAuthenticated(ctx) {
return fmt.Errorf("you must be authenticated with Cline to select an organization. Run 'cline auth' to sign in")
}
// Get client
client, err := global.GetDefaultClient(ctx)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// Fetch user organizations
orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to fetch organizations: %w", err)
}
organizations := orgsResponse.GetOrganizations()
if len(organizations) == 0 {
fmt.Println("You don't have any organizations yet.")
fmt.Println("Visit https://app.cline.bot/dashboard to create an organization.")
return HandleAuthMenuNoArgs(ctx)
}
// Build options list: Personal + Organizations
var options []huh.Option[string]
options = append(options, huh.NewOption("Personal", "personal"))
for _, org := range organizations {
displayName := org.Name
// Show active indicator
if org.Active {
displayName = fmt.Sprintf("%s (active)", displayName)
}
options = append(options, huh.NewOption(displayName, org.OrganizationId))
}
options = append(options, huh.NewOption("(Cancel)", "cancel"))
// Show selection menu
var selected string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Select which account to use").
Options(options...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return fmt.Errorf("failed to select organization: %w", err)
}
if selected == "cancel" {
return HandleAuthMenuNoArgs(ctx)
}
// Set the organization
var orgId *string
if selected != "personal" {
orgId = &selected
}
req := &cline.UserOrganizationUpdateRequest{
OrganizationId: orgId,
}
if _, err := client.Account.SetUserOrganization(ctx, req); err != nil {
return fmt.Errorf("failed to set organization: %w", err)
}
if selected == "personal" {
fmt.Println("✓ Switched to personal account")
} else {
// Find the org name to display
var orgName string
for _, org := range organizations {
if org.OrganizationId == selected {
orgName = org.Name
break
}
}
fmt.Printf("✓ Switched to organization: %s\n", orgName)
}
return HandleAuthMenuNoArgs(ctx)
}
-323
View File
@@ -1,323 +0,0 @@
package auth
import (
"context"
"fmt"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
// contextKey is a distinct type for context keys to avoid collisions
type contextKey string
const authInstanceAddressKey contextKey = "authInstanceAddress"
// AuthAction represents the type of authentication action
type AuthAction string
const (
AuthActionClineLogin AuthAction = "cline_login"
AuthActionBYOSetup AuthAction = "provider_setup"
AuthActionChangeClineModel AuthAction = "change_cline_model"
AuthActionSelectOrganization AuthAction = "select_organization"
AuthActionSelectProvider AuthAction = "select_provider"
AuthActionExit AuthAction = "exit_wizard"
)
// Cline Auth Menu
// Example Layout
//
// ┃ Cline Account: <authenticated/not authenticated>
// ┃ Active Provider: <provider name or none configured>
// ┃ Active Model: <model name or none configured>
// ┃
// ┃ What would you like to do?
// ┃ Change Cline model (only if authenticated) - hidden if not authenticated
// ┃ Authenticate with Cline account / Sign out of Cline - changes based on auth status
// ┃ Select active provider (Cline or BYO) - always shown. Used to switch between Cline and BYO providers
// ┃ Configure BYO API providers - always shown. Launches provider setup wizard
// ┃ Exit authorization wizard - always shown. Exits the auth menu
// RunAuthFlow is the entry point for the entire auth flow with instance management
// It spawns a fresh instance for auth operations and cleans it up when done
func RunAuthFlow(ctx context.Context, args []string) error {
// Spawn a fresh instance for auth operations
instanceInfo, err := global.Clients.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start auth instance: %w", err)
}
// Cleanup when done (success, error, or panic)
defer func() {
verboseLog("Shutting down auth instance at %s", instanceInfo.Address)
if err := global.KillInstanceByAddress(context.Background(), global.Clients.GetRegistry(), instanceInfo.Address); err != nil {
verboseLog("Warning: Failed to kill auth instance: %v", err)
}
}()
// Store instance address in context for all auth handlers to use
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.Address)
// Route to existing auth flow
return HandleAuthCommand(authCtx, args)
}
// Main entry point for handling the `cline auth` command
// HandleAuthCommand routes the auth command based on the number of arguments
func HandleAuthCommand(ctx context.Context, args []string) error {
// Check if flags are provided for quick setup
if QuickProvider != "" || QuickAPIKey != "" || QuickModelID != "" || QuickBaseURL != "" {
if QuickProvider == "" || QuickAPIKey == "" || QuickModelID == "" {
return fmt.Errorf("quick setup requires --provider, --apikey, and --modelid flags. Use 'cline auth --help' for more information")
}
return QuickSetupFromFlags(ctx, QuickProvider, QuickAPIKey, QuickModelID, QuickBaseURL)
}
switch len(args) {
case 0:
// No args: Show uth wizard
return HandleAuthMenuNoArgs(ctx)
case 1, 2, 3, 4:
fmt.Println("Invalid positional arguments. Correct usage:")
fmt.Println(" cline auth --provider <provider> --apikey <key> --modelid <model> --baseurl <optional>")
return nil
default:
return fmt.Errorf("too many arguments. Use flags for quick setup: --provider, --apikey, --modelid --baseurl(optional)")
}
}
// getAuthInstanceAddress retrieves the auth instance address from context
// Returns empty string if not found (falls back to default behavior)
func getAuthInstanceAddress(ctx context.Context) string {
if addr, ok := ctx.Value(authInstanceAddressKey).(string); ok {
return addr
}
return ""
}
// HandleAuthMenuNoArgs prepares the auth menu when no arguments are provided
func HandleAuthMenuNoArgs(ctx context.Context) error {
// Check if Cline is authenticated
isClineAuth := IsAuthenticated(ctx)
// Get current provider config for display
var currentProvider string
var currentModel string
if manager, err := createTaskManager(ctx); err == nil {
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
if providerList.ActProvider != nil {
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
currentModel = providerList.ActProvider.ModelID
}
}
}
// Fetch organizations if authenticated
var hasOrganizations bool
if isClineAuth {
if client, err := global.GetDefaultClient(ctx); err == nil {
if orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{}); err == nil {
hasOrganizations = len(orgsResponse.GetOrganizations()) > 0
}
}
}
action, err := ShowAuthMenuWithStatus(isClineAuth, hasOrganizations, currentProvider, currentModel)
if err != nil {
// Check if user cancelled - propagate for clean exit
if err == huh.ErrUserAborted {
return huh.ErrUserAborted
}
return err
}
switch action {
case AuthActionClineLogin:
return HandleClineAuth(ctx)
case AuthActionBYOSetup:
return HandleAPIProviderSetup(ctx)
case AuthActionChangeClineModel:
return HandleChangeClineModel(ctx)
case AuthActionSelectOrganization:
return HandleSelectOrganization(ctx)
case AuthActionSelectProvider:
return HandleSelectProvider(ctx)
case AuthActionExit:
return nil
default:
return fmt.Errorf("invalid action")
}
}
// ShowAuthMenuWithStatus displays the main auth menu with Cline + provider status
func ShowAuthMenuWithStatus(isClineAuthenticated bool, hasOrganizations bool, currentProvider, currentModel string) (AuthAction, error) {
var action AuthAction
var options []huh.Option[AuthAction]
// Build menu options based on authentication status
if isClineAuthenticated {
options = []huh.Option[AuthAction]{
huh.NewOption("Change Cline model", AuthActionChangeClineModel),
}
// Add organization selection if user has organizations
if hasOrganizations {
options = append(options, huh.NewOption("Select organization", AuthActionSelectOrganization))
}
options = append(options,
huh.NewOption("Sign out of Cline", AuthActionClineLogin),
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
huh.NewOption("Configure BYO API providers", AuthActionBYOSetup),
huh.NewOption("Exit authorization wizard", AuthActionExit),
)
} else {
options = []huh.Option[AuthAction]{
huh.NewOption("Authenticate with Cline account", AuthActionClineLogin),
huh.NewOption("Select active provider (Cline or BYO)", AuthActionSelectProvider),
huh.NewOption("Configure BYO API providers", AuthActionBYOSetup),
huh.NewOption("Exit authorization wizard", AuthActionExit),
}
}
// Determine menu title based on status
var title string
renderer := display.NewRenderer(global.Config.OutputFormat)
// Always show Cline authentication status
if isClineAuthenticated {
title = fmt.Sprintf("Cline Account: %s Authenticated\n", renderer.Green("✓"))
} else {
title = fmt.Sprintf("Cline Account: %s Not authenticated\n", renderer.Red("✗"))
}
// Show active provider and model if configured (regardless of Cline auth status)
if currentProvider != "" && currentModel != "" {
title += fmt.Sprintf("Active Provider: %s\nActive Model: %s\n",
renderer.White(currentProvider),
renderer.White(currentModel))
}
// Always end with a huh?
title += "\nWhat would you like to do?"
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[AuthAction]().
Title(title).
Options(options...).
Value(&action),
),
)
if err := form.Run(); err != nil {
// Check if user cancelled with Control-C
if err == huh.ErrUserAborted {
// Return the error to allow deferred cleanup to run
return "", huh.ErrUserAborted
}
return "", fmt.Errorf("failed to get menu choice: %w", err)
}
return action, nil
}
// HandleAPIProviderSetup launches the API provider configuration wizard
func HandleAPIProviderSetup(ctx context.Context) error {
wizard, err := NewProviderWizard(ctx)
if err != nil {
return fmt.Errorf("failed to create provider wizard: %w", err)
}
return wizard.Run()
}
// HandleSelectProvider allows users to switch between Cline provider and BYO providers
func HandleSelectProvider(ctx context.Context) error {
// Get task manager
manager, err := createTaskManager(ctx)
if err != nil {
return fmt.Errorf("failed to create task manager: %w", err)
}
// Detect all providers with valid configurations (is an API key present)
availableProviders, err := DetectAllConfiguredProviders(ctx, manager)
if err != nil {
return fmt.Errorf("failed to detect configured providers: %w", err)
}
// Build list of available providers
var providerOptions []huh.Option[string]
var providerMapping = make(map[string]cline.ApiProvider)
// Add each configured provider to the selection menu
for _, provider := range availableProviders {
providerName := GetProviderDisplayName(provider)
providerKey := fmt.Sprintf("provider_%d", provider)
providerOptions = append(providerOptions, huh.NewOption(providerName, providerKey))
providerMapping[providerKey] = provider
}
if len(providerOptions) == 0 {
fmt.Println("No providers available. Please configure a provider first.")
return HandleAuthMenuNoArgs(ctx)
}
providerOptions = append(providerOptions, huh.NewOption("(Cancel)", "cancel"))
// Show selection menu
var selected string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Select which provider to use").
Options(providerOptions...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
// Check if user cancelled with Control-C
if err == huh.ErrUserAborted {
return huh.ErrUserAborted
}
return fmt.Errorf("failed to select provider: %w", err)
}
if selected == "cancel" {
return HandleAuthMenuNoArgs(ctx)
}
// Get the selected provider
selectedProvider := providerMapping[selected]
// Apply the selected provider
if selectedProvider == cline.ApiProvider_CLINE {
// Configure Cline as the active provider
return SelectClineModel(ctx, manager)
} else {
// Switch to the selected BYO provider
return SwitchToBYOProvider(ctx, manager, selectedProvider)
}
}
// createTaskManager is a helper to create a task manager (avoids import cycles)
// Uses the auth instance address from context if available, otherwise falls back to default
func createTaskManager(ctx context.Context) (*task.Manager, error) {
authAddr := getAuthInstanceAddress(ctx)
if authAddr != "" {
return task.NewManagerForAddress(ctx, authAddr)
}
return task.NewManagerForDefault(ctx)
}
func verboseLog(format string, args ...interface{}) {
if global.Config != nil && global.Config.Verbose {
fmt.Printf("[VERBOSE] "+format+"\n", args...)
}
}
-130
View File
@@ -1,130 +0,0 @@
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
}
-247
View File
@@ -1,247 +0,0 @@
package auth
import (
"context"
"fmt"
"strings"
"time"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
// Package-level variables for command-line flags
var (
QuickProvider string // Provider ID (e.g., "openai", "anthropic")
QuickAPIKey string // API key for the provider
QuickModelID string // Model ID to configure
QuickBaseURL string // Base URL (optional, for openai compatible only)
)
// QuickSetupFromFlags performs quick setup using command-line flags
// Returns error if validation fails or configuration cannot be applied
func QuickSetupFromFlags(ctx context.Context, provider, apiKey, modelID, baseURL string) error {
// Validate all input parameters
providerEnum, err := validateQuickSetupInputs(provider, apiKey, modelID, baseURL)
if err != nil {
return err
}
// Create task manager for state operations
manager, err := createTaskManager(ctx)
if err != nil {
return fmt.Errorf("failed to create task manager: %w", err)
}
// Validate and fetch model information if needed
finalModelID, modelInfo, err := validateAndFetchModel(ctx, manager, providerEnum, modelID, apiKey)
if err != nil {
return fmt.Errorf("model validation failed: %w", err)
}
// For Ollama, baseURL is stored in the API key field
finalAPIKey := apiKey
finalBaseURL := baseURL
if providerEnum == cline.ApiProvider_OLLAMA {
if baseURL != "" {
finalAPIKey = baseURL
finalBaseURL = ""
} else if apiKey != "" {
// User provided API key for Ollama - treat it as baseURL
finalAPIKey = apiKey
finalBaseURL = ""
} else {
// Use default Ollama baseURL
finalAPIKey = "http://localhost:11434"
finalBaseURL = ""
}
}
// Configure the provider using existing AddProviderPartial function
if err := AddProviderPartial(ctx, manager, providerEnum, finalModelID, finalAPIKey, finalBaseURL, modelInfo); err != nil {
return fmt.Errorf("failed to configure provider: %w", err)
}
// Set the provider as active for both Plan and Act modes
if err := UpdateProviderPartial(ctx, manager, providerEnum, ProviderUpdatesPartial{}, true); err != nil {
return fmt.Errorf("failed to set provider as active: %w", err)
}
// Mark welcome view as completed
if err := markWelcomeViewCompleted(ctx, manager); err != nil {
// Non-fatal error, just log it
if global.Config.Verbose {
fmt.Printf("[DEBUG] Warning: failed to mark welcome view as completed: %v\n", err)
}
}
// WORKAROUND: Wait for debounced state persistence to complete
// Fixes `cline auth` issue when ran in docker environments
// TODO: implement better solution w/ changes in StateManager
time.Sleep(600 * time.Millisecond)
// Success message
fmt.Printf("\n✓ Successfully configured %s provider\n", GetProviderDisplayName(providerEnum))
fmt.Printf(" Model: %s\n", finalModelID)
if providerEnum == cline.ApiProvider_OLLAMA {
fmt.Printf(" Base URL: %s\n", finalAPIKey)
} else {
fmt.Println(" API Key: Configured")
}
if finalBaseURL != "" {
fmt.Printf(" Custom Base URL: %s\n", finalBaseURL)
}
fmt.Println("\nYou can now use Cline with this provider.")
fmt.Println("Run 'cline start' to begin a new task.")
return nil
}
// validateQuickSetupInputs validates all input parameters for quick setup
// Returns the validated provider enum or an error if validation fails
func validateQuickSetupInputs(provider, apiKey, modelID, baseURL string) (cline.ApiProvider, error) {
// Validate required parameters
if provider == "" {
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("provider is required. Use --provider or -p flag")
}
if strings.TrimSpace(apiKey) == "" && provider != "ollama" {
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("API key is required for %s provider. Use --apikey or -k flag", provider)
}
if strings.TrimSpace(modelID) == "" {
return cline.ApiProvider_ANTHROPIC, fmt.Errorf("model ID is required. Use --modelid or -m flag")
}
// Validate and map provider string to enum
providerEnum, err := validateQuickSetupProvider(provider)
if err != nil {
return cline.ApiProvider_ANTHROPIC, err
}
// Validate that baseURL is only provided for OpenAI-compatible providers
if err := validateBaseURL(baseURL, providerEnum); err != nil {
return cline.ApiProvider_ANTHROPIC, err
}
return providerEnum, nil
}
// validateBaseURL checks if the user's input includes a baseURL for a provider other than OpenAI (compatible)
// Returns error if baseURL is provided for unsupported providers
func validateBaseURL(baseURL string, providerEnum cline.ApiProvider) error {
if providerEnum != cline.ApiProvider_OPENAI {
if baseURL != "" {
return fmt.Errorf("base URL is only supported for OpenAI and OpenAI-compatible providers")
}
}
return nil
}
// validateQuickSetupProvider validates the provider ID and returns the enum value
// Returns error if provider is invalid or not supported for quick setup
func validateQuickSetupProvider(providerID string) (cline.ApiProvider, error) {
// Normalize provider ID (trim whitespace, lowercase)
normalizedID := strings.TrimSpace(strings.ToLower(providerID))
// Explicitly block Bedrock
if normalizedID == "bedrock" {
return cline.ApiProvider_BEDROCK, fmt.Errorf("bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup: cline auth")
}
// Map provider string to enum using existing function
provider, ok := mapProviderStringToEnum(normalizedID)
if !ok {
// Provider not found - provide helpful error message
supportedProviders := []string{
"openai-native", "openai", "anthropic", "gemini",
"openrouter", "xai", "cerebras", "ollama",
}
return cline.ApiProvider_ANTHROPIC, fmt.Errorf(
"invalid provider '%s'. Supported providers: %s",
providerID,
strings.Join(supportedProviders, ", "),
)
}
// Validate against supported quick setup providers
supportedProviders := map[cline.ApiProvider]bool{
cline.ApiProvider_OPENAI_NATIVE: true,
cline.ApiProvider_OPENAI: true,
cline.ApiProvider_ANTHROPIC: true,
cline.ApiProvider_GEMINI: true,
cline.ApiProvider_OPENROUTER: true,
cline.ApiProvider_XAI: true,
cline.ApiProvider_CEREBRAS: true,
cline.ApiProvider_OLLAMA: true,
cline.ApiProvider_NOUSRESEARCH: true,
}
if !supportedProviders[provider] {
return provider, fmt.Errorf(
"provider '%s' is not supported for quick setup. Please use interactive setup: cline auth",
providerID,
)
}
return provider, nil
}
// validateAndFetchModel validates the model ID or fetches from provider if needed
// Returns the final model ID and optional model info
// For providers with static models, validates against the list
// For providers with dynamic models, fetches the list if possible
func validateAndFetchModel(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID, apiKey string) (string, interface{}, error) {
// Normalize model ID
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return "", nil, fmt.Errorf("model ID cannot be empty")
}
// For most providers, we trust the user's input since we can't easily validate without making API calls
// The actual validation will happen when the model is used
switch provider {
case cline.ApiProvider_OPENROUTER:
// OpenRouter supports model info fetching, but it requires an API call
// For quick setup, we'll trust the user's input and return nil for model info
// The actual model info will be fetched when needed
if global.Config.Verbose {
fmt.Printf("[DEBUG] OpenRouter model ID: %s (will be validated on first use)\n", modelID)
}
return modelID, nil, nil
case cline.ApiProvider_OLLAMA:
// Ollama models can be validated by fetching the list, but this requires the server to be running
// For quick setup, we'll trust the user's input
if global.Config.Verbose {
fmt.Printf("[DEBUG] Ollama model ID: %s (will be validated when server is accessible)\n", modelID)
}
return modelID, nil, nil
default:
// For other providers (Anthropic, OpenAI, Gemini, XAI, Cerebras), trust user input
// Model validation will occur when the model is actually used
if global.Config.Verbose {
fmt.Printf("[DEBUG] %s model ID: %s (will be validated on first use)\n", GetProviderDisplayName(provider), modelID)
}
return modelID, nil, nil
}
}
// markWelcomeViewCompleted marks the welcome view as completed in the state
// This prevents the welcome view from showing up after quick setup
func markWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
// Use the State service to update the welcome view flag
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
if err != nil {
return fmt.Errorf("failed to mark welcome view as completed: %w", err)
}
if global.Config.Verbose {
fmt.Println("[DEBUG] Marked welcome view as completed")
}
return nil
}
-141
View File
@@ -1,141 +0,0 @@
package auth
import (
"context"
"fmt"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
// DefaultClineModelID is the default model ID for Cline provider.
// Cline uses OpenRouter-compatible model IDs.
const DefaultClineModelID = "anthropic/claude-sonnet-4.5"
// FetchClineModels fetches available Cline models from Cline Core.
// Note: Cline provider uses OpenRouter-compatible API and model format.
// The models are fetched using the same method as OpenRouter.
func FetchClineModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
if global.Config.Verbose {
fmt.Println("Fetching Cline models (using OpenRouter-compatible API)")
}
// Cline uses OpenRouter model fetching
models, err := FetchOpenRouterModels(ctx, manager)
if err != nil {
return nil, fmt.Errorf("failed to fetch Cline models: %w", err)
}
return models, nil
}
// GetClineModelInfo retrieves information for a specific Cline model.
func GetClineModelInfo(modelID string, models map[string]*cline.OpenRouterModelInfo) (*cline.OpenRouterModelInfo, error) {
modelInfo, exists := models[modelID]
if !exists {
return nil, fmt.Errorf("model %s not found", modelID)
}
return modelInfo, nil
}
// SetDefaultClineModel configures the default Cline model after authentication.
// This is called automatically after successful Cline sign-in.
func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error {
// Fetch available models
models, err := FetchClineModels(ctx, manager)
if err != nil {
// If we can't fetch models, we'll use the default without model info
fmt.Printf("Warning: Could not fetch Cline models: %v\n", err)
fmt.Printf("Using default model: %s\n", DefaultClineModelID)
return applyDefaultClineModel(ctx, manager, nil)
}
// Check if default model is available
modelInfo, err := GetClineModelInfo(DefaultClineModelID, models)
if err != nil {
fmt.Printf("Warning: Default model not found: %v\n", err)
// Try to use any available model
for modelID := range models {
fmt.Printf("Using available model: %s\n", modelID)
return applyClineModelConfiguration(ctx, manager, modelID, models[modelID])
}
return fmt.Errorf("no usable Cline models found")
}
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
return err
}
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
}
return nil
}
// SelectClineModel presents a menu to select a Cline model and applies the configuration.
func SelectClineModel(ctx context.Context, manager *task.Manager) error {
// Fetch models (uses OpenRouter-compatible format)
models, err := FetchClineModels(ctx, manager)
if err != nil {
return fmt.Errorf("failed to fetch Cline models: %w", err)
}
// Convert to interface map for generic utilities
modelMap := ConvertOpenRouterModelsToInterface(models)
// Get model IDs as a sorted list
modelIDs := ConvertModelsMapToSlice(modelMap)
// Display selection menu
selectedModelID, err := DisplayModelSelectionMenu(modelIDs, "Cline")
if err != nil {
return fmt.Errorf("model selection failed: %w", err)
}
// Get the selected model info
modelInfo := models[selectedModelID]
// Apply the configuration
if err := applyClineModelConfiguration(ctx, manager, selectedModelID, modelInfo); err != nil {
return err
}
fmt.Println()
// Return to main auth menu after model selection
return HandleAuthMenuNoArgs(ctx)
}
// applyClineModelConfiguration applies a Cline model configuration to both Act and Plan modes using UpdateProviderPartial.
// Cline uses OpenRouter-compatible model format.
func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, modelID string, modelInfo *cline.OpenRouterModelInfo) error {
provider := cline.ApiProvider_CLINE
updates := ProviderUpdatesPartial{
ModelID: &modelID,
ModelInfo: modelInfo,
}
return UpdateProviderPartial(ctx, manager, provider, updates, true)
}
func applyDefaultClineModel(ctx context.Context, manager *task.Manager, modelInfo *cline.OpenRouterModelInfo) error {
if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil {
return err
}
if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil {
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
}
return nil
}
func setWelcomeViewCompletedWithManager(ctx context.Context, manager *task.Manager) error {
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
return err
}
-156
View File
@@ -1,156 +0,0 @@
package auth
import (
"context"
"fmt"
"os"
"sort"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
"golang.org/x/term"
)
// FetchOpenRouterModels fetches available OpenRouter models from Cline Core
func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) {
resp, err := manager.GetClient().Models.RefreshOpenRouterModelsRpc(ctx, &cline.EmptyRequest{})
if err != nil {
return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err)
}
return resp.Models, nil
}
// FetchOcaModels fetches available Oca models from Cline Core
func FetchOcaModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OcaModelInfo, error) {
resp, err := manager.GetClient().Models.RefreshOcaModels(ctx, &cline.StringRequest{})
if err != nil {
return nil, fmt.Errorf("failed to fetch Oca models: %w", err)
}
return resp.Models, nil
}
// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map.
// This allows OpenRouter and Cline models to be used with the generic fetching utilities.
func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} {
result := make(map[string]interface{}, len(models))
for k, v := range models {
result[k] = v
}
return result
}
// FetchOpenAiModels fetches available OpenAI models from Cline Core
// Takes the API key and returns a list of model IDs
func FetchOpenAiModels(ctx context.Context, manager *task.Manager, baseURL, apiKey string) ([]string, error) {
req := &cline.OpenAiModelsRequest{
BaseUrl: baseURL,
ApiKey: apiKey,
}
resp, err := manager.GetClient().Models.RefreshOpenAiModels(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch OpenAI models: %w", err)
}
return resp.Values, nil
}
// FetchOllamaModels fetches available Ollama models from Cline Core
// Takes the base URL (empty string for default) and returns a list of model IDs
func FetchOllamaModels(ctx context.Context, manager *task.Manager, baseURL string) ([]string, error) {
req := &cline.StringRequest{
Value: baseURL,
}
resp, err := manager.GetClient().Models.GetOllamaModels(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Ollama models: %w", err)
}
return resp.Values, nil
}
// DisplayModelSelectionMenu shows an interactive menu for selecting a model from a list.
// Models are displayed alphabetically. Uses model ID as the option value to avoid
// index-based bugs when list order changes.
// Returns the selected model ID.
func DisplayModelSelectionMenu(models []string, providerName string) (string, error) {
if len(models) == 0 {
return "", fmt.Errorf("no models available for selection")
}
// Use model ID as the value (not index) to avoid positional coupling bugs
var selectedModel string
options := make([]huh.Option[string], len(models))
for i, model := range models {
options[i] = huh.NewOption(model, model)
}
title := fmt.Sprintf("Select a %s model", providerName)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(title).
Options(options...).
Height(calculateSelectHeight()).
Filtering(true).
Value(&selectedModel),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("failed to select model: %w", err)
}
return selectedModel, nil
}
// ConvertModelsMapToSlice converts a map of models to a sorted slice of model IDs.
// This is useful for displaying models in a consistent order in UI components.
func ConvertModelsMapToSlice(models map[string]interface{}) []string {
result := make([]string, 0, len(models))
for modelID := range models {
result = append(result, modelID)
}
// Sort alphabetically for consistent display
sort.Strings(result)
return result
}
// ConvertOcaModelsToInterface converts Oca model map to generic interface map.
// This allows Oca and Cline models to be used with the generic fetching utilities.
func ConvertOcaModelsToInterface(models map[string]*cline.OcaModelInfo) map[string]interface{} {
result := make(map[string]interface{}, len(models))
for k, v := range models {
result[k] = v
}
return result
}
// getTerminalHeight returns the terminal height (rows)
func getTerminalHeight() int {
_, height, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || height <= 0 {
return 25 // safe fallback for non-TTY or errors
}
return height
}
// calculateSelectHeight computes appropriate height for Select component
// Reserves space for title, search UI, and margins
func calculateSelectHeight() int {
height := getTerminalHeight()
// Reserve ~10 rows for UI chrome (title, search, margins)
visibleRows := height - 10
// Clamp between 8 (minimum usable) and 25 (maximum before unwieldy)
if visibleRows < 8 {
return 8
}
if visibleRows > 25 {
return 25
}
return visibleRows
}
-69
View File
@@ -1,69 +0,0 @@
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
}
-184
View File
@@ -1,184 +0,0 @@
package auth
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/cline/grpc-go/cline"
)
// BYOProviderOption represents a selectable BYO (bring-your-own) provider option
type BYOProviderOption struct {
Name string
Provider cline.ApiProvider
}
// GetBYOProviderList returns the list of supported BYO providers for CLI configuration.
// This list excludes Cline provider which is handled separately.
func GetBYOProviderList() []BYOProviderOption {
return []BYOProviderOption{
{Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC},
{Name: "OpenAI Compatible", Provider: cline.ApiProvider_OPENAI},
{Name: "OpenAI (Official)", Provider: cline.ApiProvider_OPENAI_NATIVE},
{Name: "OpenRouter", Provider: cline.ApiProvider_OPENROUTER},
{Name: "X AI (Grok)", Provider: cline.ApiProvider_XAI},
{Name: "AWS Bedrock", Provider: cline.ApiProvider_BEDROCK},
{Name: "Google Gemini", Provider: cline.ApiProvider_GEMINI},
{Name: "Ollama", Provider: cline.ApiProvider_OLLAMA},
{Name: "Cerebras", Provider: cline.ApiProvider_CEREBRAS},
{Name: "NousResearch", Provider: cline.ApiProvider_NOUSRESEARCH},
{Name: "Oracle Code Assist", Provider: cline.ApiProvider_OCA},
}
}
// SelectBYOProvider displays a menu for selecting a BYO provider.
func SelectBYOProvider() (cline.ApiProvider, error) {
providers := GetBYOProviderList()
var selectedIndex int
options := make([]huh.Option[int], len(providers)+1)
for i, provider := range providers {
options[i] = huh.NewOption(provider.Name, i)
}
options[len(providers)] = huh.NewOption("(Cancel)", -1)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Select an API provider").
Options(options...).
Value(&selectedIndex),
),
)
if err := form.Run(); err != nil {
return 0, fmt.Errorf("failed to select provider: %w", err)
}
if selectedIndex == -1 {
return 0, fmt.Errorf("provider selection cancelled")
}
return providers[selectedIndex].Provider, nil
}
// SupportsBYOModelFetching returns true if the provider supports fetching models dynamically
// from a remote API, or if it has a static list of predefined models.
// This is used to determine whether to show a model list before prompting for manual entry.
func SupportsBYOModelFetching(provider cline.ApiProvider) bool {
switch provider {
case cline.ApiProvider_OPENROUTER:
return true
case cline.ApiProvider_OPENAI:
return true
case cline.ApiProvider_OLLAMA:
return true
case cline.ApiProvider_OCA:
return true
}
return SupportsStaticModelList(provider)
}
// GetBYOProviderPlaceholder returns a placeholder model ID for manual entry based on provider.
func GetBYOProviderPlaceholder(provider cline.ApiProvider) string {
switch provider {
case cline.ApiProvider_ANTHROPIC:
return "e.g., claude-sonnet-4-5-20250929"
case cline.ApiProvider_OPENAI:
return "e.g., openai/gpt-oss-120b"
case cline.ApiProvider_OPENAI_NATIVE:
return "e.g., gpt-5-2025-08-07"
case cline.ApiProvider_OPENROUTER:
return "e.g., google/gemini-2.0-flash-exp:free"
case cline.ApiProvider_XAI:
return "e.g., grok-code-fast-1"
case cline.ApiProvider_BEDROCK:
return "e.g., anthropic.claude-sonnet-4-5-20250929-v1:0"
case cline.ApiProvider_GEMINI:
return "e.g., gemini-2.5-pro"
case cline.ApiProvider_OLLAMA:
return "e.g., qwen3-coder:30b"
case cline.ApiProvider_CEREBRAS:
return "e.g., gpt-oss-120b"
case cline.ApiProvider_NOUSRESEARCH:
return "e.g., Hermes-4-405B"
case cline.ApiProvider_OCA:
return "e.g., oca/llama4"
default:
return "Enter model ID"
}
}
// GetBYOAPIKeyFieldConfig returns field configuration for API key input based on provider.
type APIKeyFieldConfig struct {
Title string
EchoMode huh.EchoMode
IsRequired bool
}
// GetBYOAPIKeyFieldConfig returns the configuration for the API key field based on provider.
func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig {
if provider == cline.ApiProvider_OLLAMA {
return APIKeyFieldConfig{
Title: "Base URL (optional, press Enter for default)",
EchoMode: huh.EchoModeNormal,
IsRequired: false,
}
}
return APIKeyFieldConfig{
Title: "API Key",
EchoMode: huh.EchoModePassword,
IsRequired: true,
}
}
// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama).
// For OpenAI (Compatible) provider, also prompts for an optional base URL.
func PromptForAPIKey(provider cline.ApiProvider) (string, string, error) {
var apiKey string
config := GetBYOAPIKeyFieldConfig(provider)
apiKeyField := huh.NewInput().
Title(config.Title).
EchoMode(config.EchoMode).
Value(&apiKey)
if config.IsRequired {
apiKeyField = apiKeyField.Validate(func(s string) error {
if s == "" {
return fmt.Errorf("API key cannot be empty")
}
return nil
})
}
form := huh.NewForm(huh.NewGroup(apiKeyField))
if err := form.Run(); err != nil {
return "", "", fmt.Errorf("failed to get API key: %w", err)
}
// For OpenAI (Compatible) provider, prompt for base URL
if provider == cline.ApiProvider_OPENAI {
var baseURL string
baseURLForm := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Base URL (optional, for OpenAI-compatible providers)").
Placeholder("e.g., https://api.example.com/v1").
Value(&baseURL).
Description("Press Enter to skip if using standard OpenAI API"),
),
)
if err := baseURLForm.Run(); err != nil {
return "", "", fmt.Errorf("failed to get base URL: %w", err)
}
return apiKey, baseURL, nil
}
return apiKey, "", nil
}
-517
View File
@@ -1,517 +0,0 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
// ProviderDisplay represents a configured provider for display purposes
type ProviderDisplay struct {
Mode string // "Plan" or "Act"
Provider cline.ApiProvider // Provider enum
ModelID string // Model identifier
HasAPIKey bool // Whether an API key is configured (never show actual key)
BaseURL string // Base URL for providers like Ollama (can be shown publicly)
}
// ProviderListResult holds the parsed provider configuration from state
type ProviderListResult struct {
PlanProvider *ProviderDisplay
ActProvider *ProviderDisplay
apiConfig map[string]interface{} // Store the raw apiConfig for scanning all providers
}
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
if global.Config.Verbose {
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
}
// Get latest state from Cline Core
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return nil, fmt.Errorf("failed to get state: %w", err)
}
stateJSON := state.StateJson
if global.Config.Verbose {
fmt.Printf("[DEBUG] Retrieved state, parsing JSON (length: %d)\n", len(stateJSON))
}
// Parse state_json as map[string]interface{}
var stateData map[string]interface{}
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
}
if global.Config.Verbose {
fmt.Printf("[DEBUG] Parsed state data with %d keys\n", len(stateData))
}
// Extract apiConfiguration object from state
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
if !ok {
if global.Config.Verbose {
fmt.Println("[DEBUG] No apiConfiguration found in state")
}
return &ProviderListResult{
apiConfig: make(map[string]interface{}),
}, nil
}
if global.Config.Verbose {
fmt.Printf("[DEBUG] Found apiConfiguration with %d keys\n", len(apiConfig))
}
// Extract plan mode configuration
planProvider := extractProviderFromState(apiConfig, "plan")
if global.Config.Verbose && planProvider != nil {
fmt.Printf("[DEBUG] Plan mode: provider=%v, model=%s\n", planProvider.Provider, planProvider.ModelID)
}
// Extract act mode configuration
actProvider := extractProviderFromState(apiConfig, "act")
if global.Config.Verbose && actProvider != nil {
fmt.Printf("[DEBUG] Act mode: provider=%v, model=%s\n", actProvider.Provider, actProvider.ModelID)
}
return &ProviderListResult{
PlanProvider: planProvider,
ActProvider: actProvider,
apiConfig: apiConfig,
}, nil
}
// GetAllReadyProviders returns all providers that have both a model and API key configured
func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
if r.apiConfig == nil {
return []*ProviderDisplay{}
}
var readyProviders []*ProviderDisplay
seenProviders := make(map[cline.ApiProvider]bool)
// Check all possible providers
allProviders := []cline.ApiProvider{
cline.ApiProvider_CLINE,
cline.ApiProvider_ANTHROPIC,
cline.ApiProvider_OPENAI,
cline.ApiProvider_OPENAI_NATIVE,
cline.ApiProvider_OPENROUTER,
cline.ApiProvider_XAI,
cline.ApiProvider_BEDROCK,
cline.ApiProvider_GEMINI,
cline.ApiProvider_OLLAMA,
cline.ApiProvider_CEREBRAS,
cline.ApiProvider_NOUSRESEARCH,
cline.ApiProvider_OCA,
cline.ApiProvider_HICAP,
}
// Check each provider to see if it's ready to use
// We use "plan" mode to check, since both plan and act should have the same providers configured
for _, provider := range allProviders {
// Skip if we've already seen this provider
if seenProviders[provider] {
continue
}
// Check if this provider has a model configured
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
// Determine if credentials exist
hasCreds := checkAPIKeyExists(r.apiConfig, provider)
// Determine readiness: OCA uses auth state presence; others need creds and model
if provider == cline.ApiProvider_OCA {
state, _ := GetLatestOCAState(context.Background(), 2 *time.Second)
if state == nil || state.User == nil {
continue
}
} else {
// Provider is not ready unless it has credentials AND a model configured
if !hasCreds || modelID == "" {
continue
}
}
// Get base URL for Ollama
baseURL := ""
if provider == cline.ApiProvider_OLLAMA {
if url, ok := r.apiConfig["ollamaBaseUrl"].(string); ok {
baseURL = url
}
}
// This provider is ready to use
readyProviders = append(readyProviders, &ProviderDisplay{
Mode: "Ready",
Provider: provider,
ModelID: modelID,
HasAPIKey: checkAPIKeyExists(r.apiConfig, provider),
BaseURL: baseURL,
})
seenProviders[provider] = true
}
return readyProviders
}
// extractProviderFromState extracts provider configuration for specific plan/act mode
func extractProviderFromState(stateData map[string]interface{}, mode string) *ProviderDisplay {
// Build key names based on mode
providerKey := mode + "ModeApiProvider"
// Extract provider string from state
providerStr, ok := stateData[providerKey].(string)
if !ok || providerStr == "" {
if global.Config.Verbose {
fmt.Printf("[DEBUG] No provider configured for %s mode\n", mode)
}
return nil
}
// Map provider string to enum
provider, ok := mapProviderStringToEnum(providerStr)
if !ok {
if global.Config.Verbose {
fmt.Printf("[DEBUG] Unknown provider type: %s\n", providerStr)
}
return nil
}
// Get provider-specific model ID
modelID := getProviderSpecificModelID(stateData, mode, provider)
// Check if API key exists
hasAPIKey := checkAPIKeyExists(stateData, provider)
// Get base URL for Ollama (can be shown publicly)
baseURL := ""
if provider == cline.ApiProvider_OLLAMA {
if url, ok := stateData["ollamaBaseUrl"].(string); ok {
baseURL = url
}
}
return &ProviderDisplay{
Mode: capitalizeMode(mode),
Provider: provider,
ModelID: modelID,
HasAPIKey: hasAPIKey,
BaseURL: baseURL,
}
}
// mapProviderStringToEnum converts provider string from state to ApiProvider enum
// Returns (provider, ok) where ok is false if the provider is unknown
func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
normalizedStr := strings.ToLower(providerStr)
// Map string values to enum values
switch normalizedStr {
case "anthropic":
return cline.ApiProvider_ANTHROPIC, true
case "openai", "openai-compatible": // internal name is 'openai', but this is actually the openai-compatible provider
return cline.ApiProvider_OPENAI, true
case "openai-native": // This is the native, official Open AI provider
return cline.ApiProvider_OPENAI_NATIVE, true
case "openrouter":
return cline.ApiProvider_OPENROUTER, true
case "xai":
return cline.ApiProvider_XAI, true
case "bedrock":
return cline.ApiProvider_BEDROCK, true
case "gemini":
return cline.ApiProvider_GEMINI, true
case "ollama":
return cline.ApiProvider_OLLAMA, true
case "cerebras":
return cline.ApiProvider_CEREBRAS, true
case "cline":
return cline.ApiProvider_CLINE, true
case "oca":
return cline.ApiProvider_OCA, true
case "hicap":
return cline.ApiProvider_HICAP, true
case "nousResearch":
return cline.ApiProvider_NOUSRESEARCH, true
default:
return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false
}
}
// GetProviderIDForEnum converts a provider enum to the provider ID string
// This is the inverse of mapProviderStringToEnum and is used for provider definitions
func GetProviderIDForEnum(provider cline.ApiProvider) string {
switch provider {
case cline.ApiProvider_ANTHROPIC:
return "anthropic"
case cline.ApiProvider_OPENAI:
return "openai-compatible"
case cline.ApiProvider_OPENAI_NATIVE:
return "openai-native"
case cline.ApiProvider_OPENROUTER:
return "openrouter"
case cline.ApiProvider_XAI:
return "xai"
case cline.ApiProvider_BEDROCK:
return "bedrock"
case cline.ApiProvider_GEMINI:
return "gemini"
case cline.ApiProvider_OLLAMA:
return "ollama"
case cline.ApiProvider_CEREBRAS:
return "cerebras"
case cline.ApiProvider_CLINE:
return "cline"
case cline.ApiProvider_OCA:
return "oca"
case cline.ApiProvider_HICAP:
return "hicap"
case cline.ApiProvider_NOUSRESEARCH:
return "nousResearch"
default:
return ""
}
}
// getProviderSpecificModelID gets the provider-specific model ID field from state
func getProviderSpecificModelID(stateData map[string]interface{}, mode string, provider cline.ApiProvider) string {
modelKey, err := GetModelIDFieldName(provider, mode)
if err != nil {
if global.Config.Verbose {
fmt.Printf("[DEBUG] Error getting model ID field name: %v\n", err)
}
return ""
}
if global.Config.Verbose {
fmt.Printf("[DEBUG] Looking for model ID in key: %s\n", modelKey)
}
// Extract model ID from state
modelID, _ := stateData[modelKey].(string)
return modelID
}
// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key)
func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
// Get field mapping from centralized function
fields, err := GetProviderFields(provider)
if err != nil {
return false
}
keyField := fields.APIKeyField
// Check if the key exists and is not empty
if value, ok := stateData[keyField]; ok {
if str, ok := value.(string); ok && str != "" {
return true
}
}
return false
}
// capitalizeMode capitalizes the mode string for display
func capitalizeMode(mode string) string {
if len(mode) == 0 {
return mode
}
return strings.ToUpper(mode[:1]) + mode[1:]
}
// GetProviderDisplayName returns a user-friendly name for the provider
func GetProviderDisplayName(provider cline.ApiProvider) string {
switch provider {
case cline.ApiProvider_ANTHROPIC:
return "Anthropic"
case cline.ApiProvider_OPENAI:
return "OpenAI Compatible"
case cline.ApiProvider_OPENAI_NATIVE:
return "OpenAI (Official)"
case cline.ApiProvider_OPENROUTER:
return "OpenRouter"
case cline.ApiProvider_XAI:
return "X AI (Grok)"
case cline.ApiProvider_BEDROCK:
return "AWS Bedrock"
case cline.ApiProvider_GEMINI:
return "Google Gemini"
case cline.ApiProvider_OLLAMA:
return "Ollama"
case cline.ApiProvider_CEREBRAS:
return "Cerebras"
case cline.ApiProvider_CLINE:
return "Cline (Official)"
case cline.ApiProvider_OCA:
return "Oracle Code Assist"
case cline.ApiProvider_HICAP:
return "Hicap"
case cline.ApiProvider_NOUSRESEARCH:
return "NousResearch"
default:
return "Unknown"
}
}
// FormatProviderList formats the complete provider list for console display
// This now shows ALL providers that have both a model and API key configured
func FormatProviderList(result *ProviderListResult) string {
var output strings.Builder
output.WriteString("\n=== Configured API Providers ===\n\n")
// Get the currently active provider
var activeProvider cline.ApiProvider
var activeProviderSet bool
if result.ActProvider != nil {
activeProvider = result.ActProvider.Provider
activeProviderSet = true
}
// Get all ready-to-use providers (those with both API key and model configured)
readyProviders := result.GetAllReadyProviders()
if len(readyProviders) == 0 {
output.WriteString(" No providers ready to use.\n")
output.WriteString(" A provider is ready when it has both a model and API key configured.\n")
output.WriteString(" Use 'Configure a new provider' to configure one.\n\n")
} else {
//output.WriteString(fmt.Sprintf(" %d provider(s) ready to use:\n\n", len(readyProviders)))
for _, display := range readyProviders {
// Check if this is the active provider
isActive := activeProviderSet && display.Provider == activeProvider
if isActive {
output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", GetProviderDisplayName(display.Provider)))
} else {
output.WriteString(fmt.Sprintf(" • %s\n", GetProviderDisplayName(display.Provider)))
}
output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID))
// Show status based on provider type
if display.Provider == cline.ApiProvider_OLLAMA {
if display.BaseURL != "" {
output.WriteString(fmt.Sprintf(" Base URL: %s\n", display.BaseURL))
} else {
output.WriteString(" Base URL: (default)\n")
}
} else if display.Provider == cline.ApiProvider_CLINE || display.Provider == cline.ApiProvider_OCA {
output.WriteString(" Status: Authenticated\n")
} else {
output.WriteString(" API Key: Configured\n")
}
output.WriteString("\n")
}
}
output.WriteString("================================\n")
return output.String()
}
// DetectAllConfiguredProviders scans the state to find all providers that have API keys configured.
// This allows switching between multiple providers even when only one is currently active.
func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([]cline.ApiProvider, error) {
verboseLog("[DEBUG] Detecting all configured providers...")
// Get latest state from Cline Core
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return nil, fmt.Errorf("failed to get state: %w", err)
}
stateJSON := state.StateJson
// Parse state_json as map[string]interface{}
var stateData map[string]interface{}
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
}
// Extract apiConfiguration object from state
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
if !ok {
verboseLog("[DEBUG] No apiConfiguration found in state")
verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData))
return []cline.ApiProvider{}, nil
}
verboseLog("[DEBUG] apiConfiguration keys: %v", getMapKeys(apiConfig))
var configuredProviders []cline.ApiProvider
// Check for Cline provider (uses authentication instead of API key)
if IsAuthenticated(ctx) {
configuredProviders = append(configuredProviders, cline.ApiProvider_CLINE)
verboseLog("[DEBUG] Cline provider is authenticated")
}
// Check OCA provider via global auth subscription (state presence)
if state, _ := GetLatestOCAState(context.Background(), 2*time.Second); state != nil && state.User != nil {
configuredProviders = append(configuredProviders, cline.ApiProvider_OCA)
verboseLog("[DEBUG] OCA provider has active auth state")
}
// Check each BYO provider for API key presence
providersToCheck := []struct {
provider cline.ApiProvider
keyField string
}{
{cline.ApiProvider_ANTHROPIC, "apiKey"},
{cline.ApiProvider_OPENAI, "openAiApiKey"},
{cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"},
{cline.ApiProvider_OPENROUTER, "openRouterApiKey"},
{cline.ApiProvider_XAI, "xaiApiKey"},
{cline.ApiProvider_BEDROCK, "awsAccessKey"},
{cline.ApiProvider_GEMINI, "geminiApiKey"},
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
{cline.ApiProvider_HICAP, "hicapApiKey"},
{cline.ApiProvider_NOUSRESEARCH, "nousResearchApiKey"},
}
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
}
@@ -1,647 +0,0 @@
package auth
import (
"context"
"fmt"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
// updateApiConfigurationPartial is a helper that calls the gRPC method with optional verbose logging.
// This replaces the Manager.updateApiConfigurationPartial method to keep auth-specific code in the auth package.
func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, request *cline.UpdateApiConfigurationPartialRequest) error {
if global.Config.Verbose {
fmt.Println("[DEBUG] Updating API configuration (partial)")
if request.UpdateMask != nil && len(request.UpdateMask.Paths) > 0 {
fmt.Printf("[DEBUG] Field mask paths: %v\n", request.UpdateMask.Paths)
}
if request.ApiConfiguration != nil {
apiConfig := request.ApiConfiguration
if apiConfig.PlanModeApiProvider != nil {
fmt.Printf("[DEBUG] Plan mode provider: %s\n", *apiConfig.PlanModeApiProvider)
}
if apiConfig.ActModeApiProvider != nil {
fmt.Printf("[DEBUG] Act mode provider: %s\n", *apiConfig.ActModeApiProvider)
}
}
}
// Call the Models service to update API configuration
_, err := manager.GetClient().Models.UpdateApiConfigurationPartial(ctx, request)
if err != nil {
return fmt.Errorf("failed to update API configuration (partial): %w", err)
}
if global.Config.Verbose {
fmt.Println("[DEBUG] API configuration updated successfully (partial)")
}
return nil
}
// ProviderFields defines all the field names associated with a specific provider
type ProviderFields struct {
APIKeyField string // API key field name (e.g., "apiKey", "openAiApiKey")
BaseURLField string // Base URL field name (optional, empty if not applicable)
PlanModeModelIDField string // Plan mode model ID field (e.g., "planModeApiModelId")
ActModeModelIDField string // Act mode model ID field (e.g., "actModeApiModelId")
PlanModeModelInfoField string // Plan mode model info field (optional, empty if not applicable)
ActModeModelInfoField string // Act mode model info field (optional, empty if not applicable)
// Provider-specific additional model ID fields
PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId"
ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId"
}
// GetProviderFields returns the field mapping for a given provider
func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
switch provider {
case cline.ApiProvider_ANTHROPIC:
return ProviderFields{
APIKeyField: "apiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
}, nil
case cline.ApiProvider_OPENAI:
return ProviderFields{
APIKeyField: "openAiApiKey",
BaseURLField: "openAiBaseUrl",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId",
ActModeProviderSpecificModelIDField: "actModeOpenAiModelId",
}, nil
case cline.ApiProvider_OPENROUTER:
return ProviderFields{
APIKeyField: "openRouterApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
ActModeModelInfoField: "actModeOpenRouterModelInfo",
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
}, nil
case cline.ApiProvider_XAI:
return ProviderFields{
APIKeyField: "xaiApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
}, nil
case cline.ApiProvider_BEDROCK:
return ProviderFields{
APIKeyField: "awsAccessKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeProviderSpecificModelIDField: "planModeAwsBedrockCustomModelBaseId",
ActModeProviderSpecificModelIDField: "actModeAwsBedrockCustomModelBaseId",
}, nil
case cline.ApiProvider_GEMINI:
return ProviderFields{
APIKeyField: "geminiApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
}, nil
case cline.ApiProvider_OPENAI_NATIVE:
return ProviderFields{
APIKeyField: "openAiNativeApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
}, nil
case cline.ApiProvider_OLLAMA:
return ProviderFields{
APIKeyField: "ollamaBaseUrl",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeProviderSpecificModelIDField: "planModeOllamaModelId",
ActModeProviderSpecificModelIDField: "actModeOllamaModelId",
}, nil
case cline.ApiProvider_CEREBRAS:
return ProviderFields{
APIKeyField: "cerebrasApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
}, nil
case cline.ApiProvider_CLINE:
return ProviderFields{
APIKeyField: "clineApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeModelInfoField: "planModeOpenRouterModelInfo",
ActModeModelInfoField: "actModeOpenRouterModelInfo",
PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId",
ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId",
}, nil
case cline.ApiProvider_OCA:
return ProviderFields{
APIKeyField: "ocaApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeModelInfoField: "planModeOcaModelInfo",
ActModeModelInfoField: "actModeOcaModelInfo",
PlanModeProviderSpecificModelIDField: "planModeOcaModelId",
ActModeProviderSpecificModelIDField: "actModeOcaModelId",
}, nil
case cline.ApiProvider_HICAP:
return ProviderFields{
APIKeyField: "hicapApiKey",
PlanModeModelInfoField: "planModeHicapModelInfo",
ActModeModelInfoField: "actModeHicapModelInfo",
PlanModeProviderSpecificModelIDField: "planModeHicapModelId",
ActModeProviderSpecificModelIDField: "actModeHicapModelId",
}, nil
case cline.ApiProvider_NOUSRESEARCH:
return ProviderFields{
APIKeyField: "nousResearchApiKey",
PlanModeModelIDField: "planModeApiModelId",
ActModeModelIDField: "actModeApiModelId",
PlanModeProviderSpecificModelIDField: "planModeNousResearchModelId",
ActModeProviderSpecificModelIDField: "actModeNousResearchModelId",
}, nil
default:
return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider)
}
}
// ProviderUpdatesPartial defines optional fields for partial provider updates
// Uses pointers to distinguish between "not provided" and "set to empty"
type ProviderUpdatesPartial struct {
ModelID *string // New model ID (optional)
APIKey *string // New API key (optional)
ModelInfo interface{} // New model info (optional, provider-specific)
BaseURL *string // New base URL (optional, e.g., for OCA, Ollama)
RefreshToken *string // New refresh token (optional, e.g., for OCA)
Mode *string // New mode (optional, e.g., "internal" or "external" for OCA)
}
// GetModelIDFieldName returns the appropriate model ID field name for a provider and mode.
// This helper centralizes the logic for determining whether to use provider-specific
// or generic model ID fields.
func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error) {
fields, err := GetProviderFields(provider)
if err != nil {
return "", err
}
if mode == "plan" {
// Use provider-specific field if available, otherwise use generic field
if fields.PlanModeProviderSpecificModelIDField != "" {
return fields.PlanModeProviderSpecificModelIDField, nil
}
return fields.PlanModeModelIDField, nil
}
// Act mode
if fields.ActModeProviderSpecificModelIDField != "" {
return fields.ActModeProviderSpecificModelIDField, nil
}
return fields.ActModeModelIDField, nil
}
// buildProviderFieldMask builds a list of camelCase field paths for the field mask.
// When includeProviderEnums is true, the provider enum fields are included (for setting active provider).
// When false, only the data fields are included (for configuring without activating).
func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeBaseURL bool, includeProviderEnums bool) []string {
var fieldPaths []string
// Include provider enums if requested (used when setting active provider)
if includeProviderEnums {
fieldPaths = append(fieldPaths, "planModeApiProvider", "actModeApiProvider")
}
// Add API key field if requested
if includeAPIKey {
fieldPaths = append(fieldPaths, fields.APIKeyField)
// Special case: Bedrock also needs secret key
if fields.APIKeyField == "awsAccessKey" {
fieldPaths = append(fieldPaths, "awsSecretKey")
}
}
// Add base URL field if requested and applicable
if includeBaseURL && fields.BaseURLField != "" {
fieldPaths = append(fieldPaths, fields.BaseURLField)
}
// Add model ID fields if requested
if includeModelID {
// Only include provider-specific fields if they exist, otherwise use generic fields
if fields.PlanModeProviderSpecificModelIDField != "" {
// Provider has specific fields - use ONLY those
fieldPaths = append(fieldPaths, fields.PlanModeProviderSpecificModelIDField)
fieldPaths = append(fieldPaths, fields.ActModeProviderSpecificModelIDField)
} else {
// Provider uses generic fields - update those
fieldPaths = append(fieldPaths, fields.PlanModeModelIDField)
fieldPaths = append(fieldPaths, fields.ActModeModelIDField)
}
}
// Add model info fields if requested and applicable
if includeModelInfo && fields.PlanModeModelInfoField != "" {
fieldPaths = append(fieldPaths, fields.PlanModeModelInfoField)
fieldPaths = append(fieldPaths, fields.ActModeModelInfoField)
}
return fieldPaths
}
// setAPIKeyField sets the appropriate API key field in the config based on the field name
func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
switch fieldName {
case "apiKey":
apiConfig.ApiKey = value
case "openAiApiKey":
apiConfig.OpenAiApiKey = value
case "openAiNativeApiKey":
apiConfig.OpenAiNativeApiKey = value
case "openRouterApiKey":
apiConfig.OpenRouterApiKey = value
case "xaiApiKey":
apiConfig.XaiApiKey = value
case "awsAccessKey":
apiConfig.AwsAccessKey = value
case "geminiApiKey":
apiConfig.GeminiApiKey = value
case "ollamaBaseUrl":
apiConfig.OllamaBaseUrl = value
case "cerebrasApiKey":
apiConfig.CerebrasApiKey = value
case "clineApiKey":
apiConfig.ClineApiKey = value
case "ocaApiKey":
apiConfig.OcaApiKey = value
case "hicapApiKey":
apiConfig.HicapApiKey = value
case "nousResearchApiKey":
apiConfig.nousResearchApiKey = value
}
}
// setProviderSpecificModelID sets the appropriate provider-specific model ID fields when possible
func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
switch fieldName {
case "planModeOpenAiModelId":
apiConfig.PlanModeOpenAiModelId = value
apiConfig.ActModeOpenAiModelId = value
case "planModeOpenRouterModelId":
apiConfig.PlanModeOpenRouterModelId = value
apiConfig.ActModeOpenRouterModelId = value
case "planModeOllamaModelId":
apiConfig.PlanModeOllamaModelId = value
apiConfig.ActModeOllamaModelId = value
case "planModeAwsBedrockCustomModelBaseId":
apiConfig.PlanModeAwsBedrockCustomModelBaseId = value
apiConfig.ActModeAwsBedrockCustomModelBaseId = value
case "planModeOcaModelId":
apiConfig.PlanModeOcaModelId = value
apiConfig.ActModeOcaModelId = value
case "planModeHicapModelId":
apiConfig.PlanModeHicapModelId = value
apiConfig.ActModeHicapModelId = value
case "planModeNousResearchModelId":
apiConfig.planModeNousResearchModelId = value
apiConfig.ActModeNousResearchModelId = value
}
}
// AddProviderPartial configures a new provider with all necessary fields using partial updates.
func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, baseURL string, modelInfo interface{}) error {
// Get field mapping for this provider
fields, err := GetProviderFields(provider)
if err != nil {
return err
}
// Build a ModelsApiConfiguration with only the relevant provider fields set
apiConfig := &cline.ModelsApiConfiguration{}
// Set API key field
if apiKey != "" || fields.APIKeyField != "ollamaBaseUrl" {
setAPIKeyField(apiConfig, fields.APIKeyField, proto.String(apiKey))
}
// Set base URL field if provided and applicable
includeBaseURL := false
if baseURL != "" && fields.BaseURLField != "" {
setBaseURLField(apiConfig, fields.BaseURLField, proto.String(baseURL))
includeBaseURL = true
}
// Set model ID fields
apiConfig.PlanModeApiModelId = proto.String(modelID)
apiConfig.ActModeApiModelId = proto.String(modelID)
// Set provider-specific model ID fields if applicable
if fields.PlanModeProviderSpecificModelIDField != "" {
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, proto.String(modelID))
}
// Set model info if applicable and provided
if fields.PlanModeModelInfoField != "" && modelInfo != nil {
if openRouterInfo, ok := modelInfo.(*cline.OpenRouterModelInfo); ok {
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
}
}
// Build field mask including all fields we're setting (without provider enums)
includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil
fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, includeBaseURL, false)
// Create field mask
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
// Apply the partial update
request := &cline.UpdateApiConfigurationPartialRequest{
ApiConfiguration: apiConfig,
UpdateMask: fieldMask,
}
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
return fmt.Errorf("failed to update API configuration: %w", err)
}
return nil
}
// UpdateProviderPartial updates specific fields for an existing provider using partial updates.
// If setAsActive is true, this will also set the provider as the active provider for both Plan and Act modes.
func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, updates ProviderUpdatesPartial, setAsActive bool) error {
// Get field mapping for this provider
fields, err := GetProviderFields(provider)
if err != nil {
return err
}
// Build a ModelsApiConfiguration with only the fields being updated
apiConfig := &cline.ModelsApiConfiguration{}
// Set provider enum for BOTH Plan and Act modes if setAsActive is true
if setAsActive {
apiConfig.PlanModeApiProvider = &provider
apiConfig.ActModeApiProvider = &provider
}
// Track what we're updating for field mask
includeAPIKey := updates.APIKey != nil
includeModelID := updates.ModelID != nil
includeModelInfo := updates.ModelInfo != nil && fields.PlanModeModelInfoField != ""
// Update API key if provided
if updates.APIKey != nil {
setAPIKeyField(apiConfig, fields.APIKeyField, updates.APIKey)
}
// Update model ID if provided
if updates.ModelID != nil {
// Only set provider-specific fields if they exist, otherwise use generic fields
if fields.PlanModeProviderSpecificModelIDField != "" {
setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, updates.ModelID)
} else {
// Provider uses generic fields - set those
apiConfig.PlanModeApiModelId = updates.ModelID
apiConfig.ActModeApiModelId = updates.ModelID
}
}
// Update model info if provided
if updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" {
if openRouterInfo, ok := updates.ModelInfo.(*cline.OpenRouterModelInfo); ok {
apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo
apiConfig.ActModeOpenRouterModelInfo = openRouterInfo
}
}
// Build field mask for only the fields being updated
fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, false, setAsActive)
// Create field mask
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
// Apply the partial update
request := &cline.UpdateApiConfigurationPartialRequest{
ApiConfiguration: apiConfig,
UpdateMask: fieldMask,
}
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
return fmt.Errorf("failed to update API configuration: %w", err)
}
return nil
}
// RemoveProviderPartial removes a provider by clearing its API key using partial updates
func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
// Get field mapping for this provider
fields, err := GetProviderFields(provider)
if err != nil {
return err
}
// Build an EMPTY ModelsApiConfiguration (or one with empty API key field)
// Fields in the mask without values will be cleared
apiConfig := &cline.ModelsApiConfiguration{}
// Build field mask with only the API key field(s)
// For Bedrock, include both access key and secret key
fieldPaths := []string{fields.APIKeyField}
if provider == cline.ApiProvider_BEDROCK {
fieldPaths = append(fieldPaths, "awsSecretKey")
}
// Create field mask
fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths}
// Apply the partial update (clearing API key by including in mask without value)
request := &cline.UpdateApiConfigurationPartialRequest{
ApiConfiguration: apiConfig,
UpdateMask: fieldMask,
}
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
return fmt.Errorf("failed to update API configuration: %w", err)
}
return nil
}
// setBaseURLField sets the appropriate base URL field in the config based on the field name
func setBaseURLField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
switch fieldName {
case "ocaBaseUrl":
apiConfig.OcaBaseUrl = value
case "ollamaBaseUrl":
apiConfig.OllamaBaseUrl = value
case "openAiBaseUrl":
apiConfig.OpenAiBaseUrl = value
case "geminiBaseUrl":
apiConfig.GeminiBaseUrl = value
case "liteLlmBaseUrl":
apiConfig.LiteLlmBaseUrl = value
case "anthropicBaseUrl":
apiConfig.AnthropicBaseUrl = value
case "requestyBaseUrl":
apiConfig.RequestyBaseUrl = value
case "lmStudioBaseUrl":
apiConfig.LmStudioBaseUrl = value
case "oca":
apiConfig.OcaBaseUrl = value
}
}
// setRefreshTokenField sets the appropriate refresh token field in the config
func setRefreshTokenField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
switch fieldName {
case "ocaRefreshToken":
apiConfig.OcaRefreshToken = value
}
}
// setModeField sets the appropriate mode field in the config
func setModeField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) {
switch fieldName {
case "ocaMode":
apiConfig.OcaMode = value
}
}
// BedrockOptionalFields holds optional configuration fields for AWS Bedrock
type BedrockOptionalFields struct {
SessionToken *string // Optional: AWS session token for temporary credentials
Region *string // Optional: AWS region
UseCrossRegionInference *bool // Optional: Enable cross-region inference
UseGlobalInference *bool // Optional: Use global inference endpoint
UsePromptCache *bool // Optional: Enable prompt caching
Authentication *string // Optional: Authentication method
UseProfile *bool // Optional: Use AWS profile
Profile *string // Optional: AWS profile name
Endpoint *string // Optional: Custom endpoint URL
}
// OcaOptionalFields holds optional configuration fields for Oracle Code Assist
type OcaOptionalFields struct {
BaseURL *string // Optional: Base URL
Mode *string // Optional: Mode ("internal" or "external")
}
// setBedrockOptionalFields sets optional Bedrock-specific fields in the API configuration
func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *BedrockOptionalFields) {
if fields == nil {
return
}
if fields.SessionToken != nil {
apiConfig.AwsSessionToken = fields.SessionToken
}
if fields.Region != nil {
apiConfig.AwsRegion = fields.Region
}
if fields.UseCrossRegionInference != nil {
apiConfig.AwsUseCrossRegionInference = fields.UseCrossRegionInference
}
if fields.UseGlobalInference != nil {
apiConfig.AwsUseGlobalInference = fields.UseGlobalInference
}
if fields.UsePromptCache != nil {
apiConfig.AwsBedrockUsePromptCache = fields.UsePromptCache
}
if fields.Authentication != nil {
apiConfig.AwsAuthentication = fields.Authentication
}
if fields.UseProfile != nil {
apiConfig.AwsUseProfile = fields.UseProfile
}
if fields.Profile != nil {
apiConfig.AwsProfile = fields.Profile
}
if fields.Endpoint != nil {
apiConfig.AwsBedrockEndpoint = fields.Endpoint
}
}
// setOcaOptionalFields sets optional Oca-specific fields in the API configuration
func setOcaOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *OcaOptionalFields) {
if fields == nil {
return
}
if fields.Mode != nil {
apiConfig.OcaMode = fields.Mode
}
if fields.BaseURL != nil {
apiConfig.OcaBaseUrl = fields.BaseURL
}
}
// buildBedrockOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string {
if fields == nil {
return nil
}
var fieldPaths []string
if fields.SessionToken != nil {
fieldPaths = append(fieldPaths, "awsSessionToken")
}
if fields.Region != nil {
fieldPaths = append(fieldPaths, "awsRegion")
}
if fields.UseCrossRegionInference != nil {
fieldPaths = append(fieldPaths, "awsUseCrossRegionInference")
}
if fields.UseGlobalInference != nil {
fieldPaths = append(fieldPaths, "awsUseGlobalInference")
}
if fields.UsePromptCache != nil {
fieldPaths = append(fieldPaths, "awsBedrockUsePromptCache")
}
if fields.Authentication != nil {
fieldPaths = append(fieldPaths, "awsAuthentication")
}
if fields.UseProfile != nil {
fieldPaths = append(fieldPaths, "awsUseProfile")
}
if fields.Profile != nil {
fieldPaths = append(fieldPaths, "awsProfile")
}
if fields.Endpoint != nil {
fieldPaths = append(fieldPaths, "awsBedrockEndpoint")
}
return fieldPaths
}
// buildOcaOptionalFieldMask builds field mask paths for Bedrock optional fields that have values
func buildOcaOptionalFieldMask(fields *OcaOptionalFields) []string {
if fields == nil {
return nil
}
var fieldPaths []string
if fields.Mode != nil {
fieldPaths = append(fieldPaths, "ocaMode")
}
if fields.BaseURL != nil {
fieldPaths = append(fieldPaths, "ocaBaseUrl")
}
return fieldPaths
}
-764
View File
@@ -1,764 +0,0 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
)
// ProviderWizard handles the interactive provider configuration process
type ProviderWizard struct {
ctx context.Context
manager *task.Manager
}
// NewProviderWizard prepares a new provider configuration wizard
func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) {
// Create task manager using auth instance from context
manager, err := createTaskManager(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create task manager: %w", err)
}
return &ProviderWizard{
ctx: ctx,
manager: manager,
}, nil
}
// showMainMenu displays the main provider configuration menu
func (pw *ProviderWizard) showMainMenu() (string, error) {
var action string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What would you like to do?").
Options(
huh.NewOption("Add or change an API provider", "add"),
huh.NewOption("Change model for API provider", "change-model"),
huh.NewOption("Remove a provider", "remove"),
huh.NewOption("List configured providers", "list"),
huh.NewOption("Return to main auth menu", "back"),
).
Value(&action),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("failed to get menu choice: %w", err)
}
return action, nil
}
// Run runs the provider configuration wizard
func (pw *ProviderWizard) Run() error {
for {
action, err := pw.showMainMenu()
if err != nil {
return err
}
switch action {
case "add":
if err := pw.handleAddProvider(); err != nil {
return err
}
case "change-model":
if err := pw.handleChangeModel(); err != nil {
return err
}
case "remove":
if err := pw.handleRemoveProvider(); err != nil {
return err
}
case "list":
if err := pw.handleListProviders(); err != nil {
return err
}
case "back":
// Return to main auth menu
return HandleAuthMenuNoArgs(pw.ctx)
}
fmt.Println()
}
}
// "Add a new provider" > handleAddProvider
func (pw *ProviderWizard) handleAddProvider() error {
// Step 1: Select provider
provider, err := SelectBYOProvider()
if err != nil {
if strings.Contains(err.Error(), "cancelled") {
return nil
}
return fmt.Errorf("provider selection failed: %w", err)
}
// Step 2: Special handling for Bedrock provider
if provider == cline.ApiProvider_BEDROCK {
return pw.handleAddBedrockProvider()
}
// Step 2b: Special handling for OCA provider
if provider == cline.ApiProvider_OCA {
return pw.handleAddOcaProvider()
}
// Step 3: Get API key first (for non-Bedrock providers)
apiKey, baseURL, err := PromptForAPIKey(provider)
if err != nil {
return fmt.Errorf("failed to get API key: %w", err)
}
// Step 4: Try to fetch models and let user select (with fallback to manual entry for providers that don't support fetch)
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
if err != nil {
return fmt.Errorf("model selection failed: %w", err)
}
// Step 5: Apply configuration using AddProviderPartial
if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, baseURL, modelInfo); err != nil {
return fmt.Errorf("failed to save configuration: %w", err)
}
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
}
fmt.Println("✓ Provider configured successfully!")
return nil
}
// handleAddBedrockProvider handles the special case of adding Bedrock provider with its multi-field form
func (pw *ProviderWizard) handleAddBedrockProvider() error {
// Step 1: Get Bedrock configuration (all credentials and optional fields)
config, err := PromptForBedrockConfig(pw.ctx, pw.manager)
if err != nil {
if strings.Contains(err.Error(), "user declined profile authentication") {
return nil
}
return fmt.Errorf("failed to get Bedrock configuration: %w", err)
}
// Step 2: Select model
modelID, modelInfo, err := pw.selectModel(cline.ApiProvider_BEDROCK, "")
if err != nil {
return fmt.Errorf("model selection failed: %w", err)
}
// Step 3: Apply Bedrock configuration
if err := ApplyBedrockConfig(pw.ctx, pw.manager, config, modelID, modelInfo); err != nil {
return fmt.Errorf("failed to save Bedrock configuration: %w", err)
}
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
}
fmt.Println("✓ Bedrock provider configured successfully!")
return nil
}
// handleAddOcaProvider handles adding Oracle Code Assist provider with optional settings and auth
func (pw *ProviderWizard) handleAddOcaProvider() error {
// Step 1: Get OCA configuration (base URL and mode)
config, err := PromptForOcaConfig(pw.ctx, pw.manager)
if err != nil {
if strings.Contains(err.Error(), "user aborted") || strings.Contains(err.Error(), "cancelled") {
return nil
}
return fmt.Errorf("failed to get OCA configuration: %w", err)
}
// Apply OCA configuration (base URL and mode)
if err := ApplyOcaConfig(pw.ctx, pw.manager, config); err != nil {
return fmt.Errorf("failed to save OCA configuration: %w", err)
}
// Step 2: Ensure OCA authentication
if err := ensureOcaAuthenticated(pw.ctx); err != nil {
return fmt.Errorf("failed to authenticate with OCA: %w", err)
}
// Step 3: Select model
modelID, _, err := pw.selectModel(cline.ApiProvider_OCA, "")
if err != nil {
return fmt.Errorf("model selection failed: %w", err)
}
// Step 4: Apply the OCA model configuration and set as active
updates := ProviderUpdatesPartial{
ModelID: &modelID,
ModelInfo: nil,
}
if err := UpdateProviderPartial(pw.ctx, pw.manager, cline.ApiProvider_OCA, updates, true); err != nil {
return fmt.Errorf("failed to save OCA configuration: %w", err)
}
if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil {
verboseLog("Warning: Failed to mark welcome view as completed: %v", err)
}
fmt.Println("✓ OCA provider configured successfully!")
return nil
}
// handleListProviders retrieves and displays configured providers
func (pw *ProviderWizard) handleListProviders() error {
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
output := FormatProviderList(result)
fmt.Println(output)
return nil
}
// selectModel attempts to fetch available models and let user select, or falls back to manual entry
func (pw *ProviderWizard) selectModel(provider cline.ApiProvider, apiKey string) (string, interface{}, error) {
// For providers that support model fetching, try to fetch and display models
canFetchModels := pw.supportsModelFetching(provider)
if canFetchModels {
fmt.Println("Fetching available models...")
models, modelInfoMap, err := pw.fetchModelsForProvider(provider, apiKey)
if err != nil {
fmt.Println("\n⚠ Unable to fetch model list from the provider. Please enter the model ID manually instead.")
if global.Config.Verbose {
fmt.Printf(" Error details: %v\n", err)
}
return pw.manualModelEntry(provider)
}
if len(models) == 0 {
fmt.Println("\n⚠ No models found from the provider. Please enter the model ID manually instead.")
return pw.manualModelEntry(provider)
}
// Let user select from available models (includes manual entry option)
modelID, err := pw.selectFromAvailableModels(models)
if err != nil {
return "", nil, fmt.Errorf("model selection failed: %w", err)
}
// Check if user chose manual entry
const manualEntryKey = "__MANUAL_ENTRY__"
if modelID == manualEntryKey {
return pw.manualModelEntry(provider)
}
// Get the model info for the selected model
var modelInfo interface{}
if modelInfoMap != nil {
modelInfo = modelInfoMap[modelID]
}
return modelID, modelInfo, nil
}
// For providers without model fetching support, use manual entry
return pw.manualModelEntry(provider)
}
// supportsModelFetching returns true if the provider supports fetching models
func (pw *ProviderWizard) supportsModelFetching(provider cline.ApiProvider) bool {
return SupportsBYOModelFetching(provider)
}
// fetchModelsForProvider fetches models for a given provider
// Supports both dynamic API fetching (OpenRouter, OpenAI, Ollama) and static model lists (Anthropic, Bedrock, Gemini, X AI)
func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, apiKey string) ([]string, map[string]interface{}, error) {
// Try dynamic/remote model fetching first
switch provider {
case cline.ApiProvider_OPENROUTER:
models, err := FetchOpenRouterModels(pw.ctx, pw.manager)
if err != nil {
return nil, nil, err
}
interfaceMap := ConvertOpenRouterModelsToInterface(models)
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
case cline.ApiProvider_OPENAI:
// For OpenAI, we need to pass the base URL and API key
baseURL := "https://api.openai.com/v1" // Default OpenAI API base URL
modelIDs, err := FetchOpenAiModels(pw.ctx, pw.manager, baseURL, apiKey)
if err != nil {
return nil, nil, err
}
// OpenAI returns just model IDs without additional info, so modelInfo map is nil
return modelIDs, nil, nil
case cline.ApiProvider_OLLAMA:
// For Ollama, apiKey actually contains the base URL (or empty for default)
baseURL := apiKey // The "API key" field for Ollama is actually the base URL
modelIDs, err := FetchOllamaModels(pw.ctx, pw.manager, baseURL)
if err != nil {
return nil, nil, err
}
// Ollama returns just model IDs without additional info, so modelInfo map is nil
return modelIDs, nil, nil
case cline.ApiProvider_OCA:
// OCA supports dynamic model fetching
models, err := FetchOcaModels(pw.ctx, pw.manager)
if err != nil {
return nil, nil, err
}
interfaceMap := ConvertOcaModelsToInterface(models)
return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil
}
// Fall back to static models for providers that don't support dynamic fetching
if SupportsStaticModelList(provider) {
modelIDs, _, err := FetchStaticModels(provider)
if err != nil {
return nil, nil, err
}
// Static models don't have detailed info maps for now, so modelInfo map is nil
return modelIDs, nil, nil
}
return nil, nil, fmt.Errorf("model fetching not supported for provider: %v", provider)
}
// selectFromAvailableModels displays available models and lets user select one.
// Includes an option to enter a model ID manually in case the desired model isn't listed.
func (pw *ProviderWizard) selectFromAvailableModels(models []string) (string, error) {
if len(models) == 0 {
return "", fmt.Errorf("no models available")
}
// Add a special "manual entry" option at the end
const manualEntryKey = "__MANUAL_ENTRY__"
// Use model ID as the value (not index)
var selectedModel string
options := make([]huh.Option[string], len(models)+1)
for i, model := range models {
options[i] = huh.NewOption(model, model)
}
// Add manual entry option at the end
options[len(models)] = huh.NewOption("Enter model ID manually...", manualEntryKey)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Select a model").
Options(options...).
Height(calculateSelectHeight()).
Filtering(true).
Value(&selectedModel),
),
)
if err := form.Run(); err != nil {
return "", fmt.Errorf("failed to select model: %w", err)
}
// If user selected manual entry, return special key to trigger manual input
if selectedModel == manualEntryKey {
return manualEntryKey, nil
}
return selectedModel, nil
}
// manualModelEntry prompts user to manually enter a model ID.
// Returns the model ID and an error. The modelInfo is always nil for manual entry.
func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string, interface{}, error) {
var modelID string
modelPlaceholder := GetBYOProviderPlaceholder(provider)
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Model ID").
Placeholder(modelPlaceholder).
Value(&modelID).
Validate(func(s string) error {
// Trim whitespace and validate
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return fmt.Errorf("model ID cannot be empty")
}
return nil
}),
),
)
if err := form.Run(); err != nil {
return "", nil, fmt.Errorf("failed to get model ID: %w", err)
}
// Trim whitespace from the final value
modelID = strings.TrimSpace(modelID)
// modelInfo is always nil for manual entry
return modelID, nil, nil
}
// handleChangeModel allows changing the model for any configured provider
func (pw *ProviderWizard) handleChangeModel() error {
// Step 1: Get current provider configurations
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
// Step 2: Get all configured providers with models
readyProviders := result.GetAllReadyProviders()
// Filter out Cline provider (it has its own model changer in the main menu)
var configurableProviders []*ProviderDisplay
for _, provider := range readyProviders {
if provider.Provider != cline.ApiProvider_CLINE {
configurableProviders = append(configurableProviders, provider)
}
}
// Step 3: Check if there are any configurable providers
if len(configurableProviders) == 0 {
fmt.Println("\nNo configurable providers found.")
fmt.Println("Note: Cline provider has its own model selection in the main menu.")
return nil
}
// Step 4: Let user select which provider to change the model for
var selectedIndex int
options := make([]huh.Option[int], len(configurableProviders)+1)
for i, providerDisplay := range configurableProviders {
displayName := fmt.Sprintf("%s (current: %s)",
GetProviderDisplayName(providerDisplay.Provider),
providerDisplay.ModelID)
options[i] = huh.NewOption(displayName, i)
}
options[len(configurableProviders)] = huh.NewOption("(Cancel)", -1)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Select provider to change model for").
Options(options...).
Value(&selectedIndex),
),
)
if err := form.Run(); err != nil {
return fmt.Errorf("failed to select provider: %w", err)
}
if selectedIndex == -1 {
return nil
}
selectedProvider := configurableProviders[selectedIndex]
provider := selectedProvider.Provider
fmt.Printf("\nChanging model for %s\n", GetProviderDisplayName(provider))
fmt.Printf("Current model: %s\n\n", selectedProvider.ModelID)
// Step 5: Retrieve API key if needed for model fetching
var apiKey string
if pw.supportsModelFetching(provider) {
// For providers that support fetching, we need to retrieve the API key from state
state, err := pw.manager.GetClient().State.GetLatestState(pw.ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get state: %w", err)
}
var stateData map[string]interface{}
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
return fmt.Errorf("failed to parse state JSON: %w", err)
}
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
if !ok {
return fmt.Errorf("no API configuration found in state")
}
apiKey = getProviderAPIKeyFromState(apiConfig, provider)
if apiKey == "" {
return fmt.Errorf("no API key found for provider %s", GetProviderDisplayName(provider))
}
}
modelID, modelInfo, err := pw.selectModel(provider, apiKey)
if err != nil {
return fmt.Errorf("model selection failed: %w", err)
}
// Step 6: Apply the model change (for both Plan and Act modes)
if err := pw.applyModelChange(provider, modelID, modelInfo); err != nil {
return fmt.Errorf("failed to apply model change: %w", err)
}
fmt.Printf("✓ Model changed successfully to: %s\n", modelID)
fmt.Println(" (Applied to both Plan and Act modes)")
return nil
}
// applyModelChange applies a model change for both Plan and Act modes using UpdateProviderPartial
func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID string, modelInfo interface{}) error {
updates := ProviderUpdatesPartial{
ModelID: &modelID,
ModelInfo: modelInfo,
}
return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, false)
}
// SwitchToBYOProvider switches to a BYO provider that's already configured.
// It retrieves the existing model configuration and sets it as the active provider for both Plan and Act modes.
func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error {
// Get the current state to retrieve the model ID and model info for this provider
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get state: %w", err)
}
// Parse state JSON
var stateData map[string]interface{}
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
return fmt.Errorf("failed to parse state JSON: %w", err)
}
// Extract apiConfiguration
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
if !ok {
return fmt.Errorf("no API configuration found in state")
}
// Get the model ID for the selected provider
modelID := getProviderModelIDFromState(apiConfig, provider)
if modelID == "" {
return fmt.Errorf("no model configured for provider %s", GetProviderDisplayName(provider))
}
// Get model info if available (for OpenRouter/Cline)
var modelInfo interface{}
if provider == cline.ApiProvider_OPENROUTER || provider == cline.ApiProvider_CLINE {
if modelInfoData, ok := apiConfig["planModeOpenRouterModelInfo"].(map[string]interface{}); ok {
modelInfo = convertMapToOpenRouterModelInfo(modelInfoData)
}
}
// Use UpdateProviderPartial to switch to this provider
updates := ProviderUpdatesPartial{
ModelID: &modelID,
ModelInfo: modelInfo,
}
if err := UpdateProviderPartial(ctx, manager, provider, updates, true); err != nil {
return fmt.Errorf("failed to switch provider: %w", err)
}
verboseLog("✓ Switched to %s\n", GetProviderDisplayName(provider))
verboseLog(" Using model: %s\n", modelID)
return HandleAuthMenuNoArgs(ctx)
}
// getProviderModelIDFromState retrieves the model ID for a specific provider from state
func getProviderModelIDFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
modelKey, err := GetModelIDFieldName(provider, "plan")
if err != nil {
return ""
}
if modelID, ok := stateData[modelKey].(string); ok {
return modelID
}
return ""
}
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
if provider == cline.ApiProvider_OCA {
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
// Return a sentinel non-empty string so upstream checks pass.
return "OCA_AUTH_VERIFIED"
}
return ""
}
fields, err := GetProviderFields(provider)
if err != nil {
return ""
}
if apiKey, ok := stateData[fields.APIKeyField].(string); ok {
return apiKey
}
return ""
}
// convertMapToOpenRouterModelInfo converts a map to OpenRouterModelInfo
func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRouterModelInfo {
info := &cline.OpenRouterModelInfo{}
if val, ok := data["description"].(string); ok {
info.Description = &val
}
if val, ok := data["contextWindow"].(float64); ok {
contextWindow := int64(val)
info.ContextWindow = &contextWindow
}
if val, ok := data["maxTokens"].(float64); ok {
maxTokens := int64(val)
info.MaxTokens = &maxTokens
}
if val, ok := data["inputPrice"].(float64); ok {
info.InputPrice = &val
}
if val, ok := data["outputPrice"].(float64); ok {
info.OutputPrice = &val
}
if val, ok := data["cacheWritesPrice"].(float64); ok {
info.CacheWritesPrice = &val
}
if val, ok := data["cacheReadsPrice"].(float64); ok {
info.CacheReadsPrice = &val
}
if val, ok := data["supportsImages"].(bool); ok {
info.SupportsImages = &val
}
if val, ok := data["supportsPromptCache"].(bool); ok {
info.SupportsPromptCache = val
}
return info
}
// handleRemoveProvider allows removing a configured provider by clearing its API key
func (pw *ProviderWizard) handleRemoveProvider() error {
// Step 1: Get current provider configurations
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
if err != nil {
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
}
// Step 2: Get all ready providers
readyProviders := result.GetAllReadyProviders()
// Filter out Cline provider (uses account auth, not API keys)
var removableProviders []*ProviderDisplay
for _, provider := range readyProviders {
if provider.Provider != cline.ApiProvider_CLINE {
removableProviders = append(removableProviders, provider)
}
}
// Step 3: Check if there are providers to remove
if len(removableProviders) == 0 {
fmt.Println("\nNo providers available to remove.")
fmt.Println("Note: Cline provider cannot be removed via this menu.")
return nil
}
// Step 4: Display selection menu
var selectedIndex int
options := make([]huh.Option[int], len(removableProviders))
for i, provider := range removableProviders {
// Mark active provider
displayName := GetProviderDisplayName(provider.Provider)
if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider {
displayName += " (ACTIVE)"
}
options[i] = huh.NewOption(displayName, i)
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Select provider to remove").
Options(options...).
Value(&selectedIndex),
),
)
if err := form.Run(); err != nil {
return fmt.Errorf("failed to select provider: %w", err)
}
selectedProvider := removableProviders[selectedIndex]
// Step 5: Check if trying to remove the active provider
if result.ActProvider != nil && selectedProvider.Provider == result.ActProvider.Provider {
fmt.Printf("\nCannot remove %s because it is currently active.\n", GetProviderDisplayName(selectedProvider.Provider))
fmt.Println("Please switch to a different provider first, then try again.")
return nil
}
// Step 6: Confirm removal
var confirm bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Are you sure you want to remove %s?", GetProviderDisplayName(selectedProvider.Provider))).
Description("This will clear the API key but preserve the model configuration.").
Value(&confirm),
),
)
if err := confirmForm.Run(); err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}
if !confirm {
fmt.Println("Removal cancelled.")
return nil
}
// Step 7: If removing OCA, sign out first
if selectedProvider.Provider == cline.ApiProvider_OCA {
if err := signOutOca(pw.ctx); err != nil {
fmt.Printf("Warning: Failed to sign out of OCA: %v\n", err)
} else {
fmt.Println("Signed out of OCA.")
}
}
// Step 8: Clear the API key for the selected provider
if err := pw.clearProviderAPIKey(selectedProvider.Provider); err != nil {
return fmt.Errorf("failed to remove provider: %w", err)
}
fmt.Printf("\n✓ %s removed successfully\n", GetProviderDisplayName(selectedProvider.Provider))
return nil
}
// clearProviderAPIKey clears the API key field for a specific provider using RemoveProviderPartial
func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error {
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
}
func signOutOca(ctx context.Context) error {
client, err := global.GetDefaultClient(ctx)
if err != nil {
return err
}
_, err = client.Ocaaccount.OcaAccountLogoutClicked(ctx, &cline.EmptyRequest{})
return err
}
func setWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error {
_, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true})
return err
}
-193
View File
@@ -1,193 +0,0 @@
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
}
-366
View File
@@ -1,366 +0,0 @@
package auth
import (
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
"github.com/cline/grpc-go/cline"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
// OcaConfig holds Oracle Code Assist (OCA) configuration fields
type OcaConfig struct {
BaseURL string
Mode string
}
// PromptForOcaConfig displays a form for OCA configuration (base URL and mode)
func PromptForOcaConfig(ctx context.Context, manager *task.Manager) (*OcaConfig, error) {
config := &OcaConfig{}
var mode string
// Collect optional settings
configForm := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Base URL").
Value(&config.BaseURL).
Description("Leave empty to use default Base URL"),
huh.NewSelect[string]().
Title("Choose OCA mode (used for authentication)").
Description("Select 'Internal' to use Cline's internal OCA, or 'External' for your own OCA instance").
Options(
huh.NewOption("Internal", "internal"),
huh.NewOption("External", "external"),
).
Value(&mode),
),
)
if err := configForm.Run(); err != nil {
return nil, fmt.Errorf("failed to get OCA configuration: %w", err)
}
// Trim whitespace from string fields
config.BaseURL = strings.TrimSpace(config.BaseURL)
config.Mode = strings.TrimSpace(mode)
return config, nil
}
// ApplyOcaConfig applies OCA configuration using partial updates
func ApplyOcaConfig(ctx context.Context, manager *task.Manager, config *OcaConfig) error {
// Build the API configuration with all OCA fields
apiConfig := &cline.ModelsApiConfiguration{}
// Set profile authentication fields (always required)
optionalFields := &OcaOptionalFields{}
// Set profile name (can be empty for default profile)
if config.BaseURL != "" {
optionalFields.BaseURL = proto.String(config.BaseURL)
}
// Set optional fields if provided
if config.Mode != "" {
optionalFields.Mode = proto.String(config.Mode)
}
// Apply all fields to the config
setOcaOptionalFields(apiConfig, optionalFields)
// Add profile authentication field paths
optionalPaths := buildOcaOptionalFieldMask(optionalFields)
// Create field mask
fieldMask := &fieldmaskpb.FieldMask{Paths: optionalPaths}
// Apply the partial update
request := &cline.UpdateApiConfigurationPartialRequest{
ApiConfiguration: apiConfig,
UpdateMask: fieldMask,
}
if err := updateApiConfigurationPartial(ctx, manager, request); err != nil {
return fmt.Errorf("failed to apply OCA configuration: %w", err)
}
return nil
}
// ===========================
// OCA Auth Listener Singleton
// ===========================
type ocaAuthStream interface {
Recv() (*cline.OcaAuthState, error)
}
// OcaAuthStatusListener manages subscription to OCA auth status updates
type OcaAuthStatusListener struct {
stream ocaAuthStream
updatesCh chan *cline.OcaAuthState
errCh chan error
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
lastState *cline.OcaAuthState
firstEventCh chan struct{}
firstEventOnce sync.Once
}
// NewOcaAuthStatusListener creates a new OCA auth status listener
func NewOcaAuthStatusListener(parentCtx context.Context) (*OcaAuthStatusListener, error) {
client, err := global.GetDefaultClient(parentCtx)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
// Keep the listener alive independently of short-lived caller contexts
ctx, cancel := context.WithCancel(context.Background())
// Subscribe to OCA auth status updates
stream, err := client.Ocaaccount.OcaSubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{})
if err != nil {
cancel()
return nil, fmt.Errorf("failed to subscribe to OCA auth updates: %w", err)
}
return &OcaAuthStatusListener{
stream: stream,
updatesCh: make(chan *cline.OcaAuthState, 10),
errCh: make(chan error, 1),
ctx: ctx,
cancel: cancel,
firstEventCh: make(chan struct{}),
}, nil
}
// Start begins listening to the auth status update stream
func (l *OcaAuthStatusListener) Start() error {
go l.readStream()
return nil
}
func (l *OcaAuthStatusListener) readStream() {
defer close(l.updatesCh)
defer close(l.errCh)
for {
select {
case <-l.ctx.Done():
return
default:
state, err := l.stream.Recv()
if err != nil {
// Propagate error and exit
if err == io.EOF {
// Treat as error to notify waiters
err = fmt.Errorf("OCA auth status stream closed")
}
select {
case l.errCh <- err:
case <-l.ctx.Done():
}
return
}
l.mu.Lock()
l.lastState = state
l.mu.Unlock()
// Notify first event waiters
l.firstEventOnce.Do(func() { close(l.firstEventCh) })
select {
case l.updatesCh <- state:
case <-l.ctx.Done():
return
}
}
}
}
// WaitForFirstEvent blocks until the first event is received or timeout occurs
func (l *OcaAuthStatusListener) WaitForFirstEvent(timeout time.Duration) error {
// Fast-path if already have a state
l.mu.RLock()
ready := l.lastState != nil
l.mu.RUnlock()
if ready {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-l.firstEventCh:
return nil
case <-timer.C:
return fmt.Errorf("timeout waiting for initial OCA auth event")
case <-l.ctx.Done():
return fmt.Errorf("OCA auth listener cancelled")
}
}
// IsAuthenticated returns true if the last known OCA auth state is authenticated
func (l *OcaAuthStatusListener) IsAuthenticated() bool {
l.mu.RLock()
defer l.mu.RUnlock()
return isOCAStateAuthenticated(l.lastState)
}
// WaitForAuthentication waits until OCA authentication succeeds or timeout occurs
func (l *OcaAuthStatusListener) WaitForAuthentication(timeout time.Duration) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
// If already authenticated, return immediately
if l.IsAuthenticated() {
return nil
}
for {
select {
case <-timer.C:
return fmt.Errorf("OCA authentication timeout after %v - please try again", timeout)
case <-l.ctx.Done():
return fmt.Errorf("OCA authentication cancelled")
case err := <-l.errCh:
return fmt.Errorf("OCA authentication stream error: %w", err)
case state := <-l.updatesCh:
if isOCAStateAuthenticated(state) {
return nil
}
}
}
}
// Stop closes the stream and cleans up resources
func (l *OcaAuthStatusListener) Stop() {
l.cancel()
}
func isOCAStateAuthenticated(state *cline.OcaAuthState) bool {
return state != nil && state.User != nil
}
// Singleton holder
var (
ocaListener *OcaAuthStatusListener
ocaListenerOnce sync.Once
ocaListenerErr error
)
// GetOcaAuthListener returns the OCA auth listener singleton
func GetOcaAuthListener(ctx context.Context) (*OcaAuthStatusListener, error) {
// Allow optional ctx: if nil, use context.TODO(). If already initialized, return singleton.
if ctx == nil {
ctx = context.TODO()
}
ocaListenerOnce.Do(func() {
l, err := NewOcaAuthStatusListener(ctx)
if err != nil {
ocaListenerErr = err
return
}
if err := l.Start(); err != nil {
ocaListenerErr = err
return
}
ocaListener = l
})
return ocaListener, ocaListenerErr
}
// IsOCAAuthenticated returns true if the global OCA auth status is authenticated.
// It attempts a brief wait for the first event to avoid stale reads.
func IsOCAAuthenticated(ctx context.Context) bool {
l, err := GetOcaAuthListener(ctx)
if err != nil {
return false
}
_ = l.WaitForFirstEvent(1 * time.Second) // best-effort
return l.IsAuthenticated()
}
// LatestState returns the last received OCA auth state (may be nil)
func (l *OcaAuthStatusListener) LatestState() *cline.OcaAuthState {
l.mu.RLock()
defer l.mu.RUnlock()
return l.lastState
}
// GetLatestOCAState returns the latest known OCA auth state, optionally waiting for the first event
func GetLatestOCAState(ctx context.Context, timeout time.Duration) (*cline.OcaAuthState, error) {
l, err := GetOcaAuthListener(ctx)
if err != nil {
return nil, err
}
if timeout > 0 {
if err := l.WaitForFirstEvent(timeout); err != nil {
return nil, err
}
}
return l.LatestState(), nil
}
// ensureOcaAuthenticated initiates OCA login (if needed) and waits for success using the singleton listener
func ensureOcaAuthenticated(ctx context.Context) error {
// Ensure listener exists
listener, err := GetOcaAuthListener(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OCA auth listener: %w", err)
}
// Briefly wait for first event to know current state
_ = listener.WaitForFirstEvent(1 * time.Second)
// If already authenticated, nothing to do
if listener.IsAuthenticated() {
fmt.Println("✓ OCA authentication already active.")
return nil
}
// Create gRPC client for initiating login
client, err := global.GetDefaultClient(ctx)
if err != nil {
return fmt.Errorf("failed to obtain client: %w", err)
}
// Start login and wait for authentication
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
// Initiate login (opens the browser with a callback URL from Cline Core)
response, err := client.Ocaaccount.OcaAccountLoginClicked(waitCtx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to initiate OCA login: %w", err)
}
fmt.Println("\nOpening browser for OCA authentication...")
if response != nil && response.Value != "" {
fmt.Printf("If the browser doesn't open automatically, visit this URL:\n%s\n\n", response.Value)
}
fmt.Println("Waiting for you to complete OCA authentication in your browser...")
fmt.Println("(This may take a few moments. Timeout: 5 minutes)")
// Block until authenticated or timeout
if err := listener.WaitForAuthentication(5 * time.Minute); err != nil {
return err
}
fmt.Println("✓ OCA authentication successful!")
return nil
}
-187
View File
@@ -1,187 +0,0 @@
package clerror
import (
"encoding/json"
"fmt"
"strings"
)
// ClineErrorType represents the category of error
type ClineErrorType string
const (
ErrorTypeAuth ClineErrorType = "auth"
ErrorTypeNetwork ClineErrorType = "network"
ErrorTypeRateLimit ClineErrorType = "rateLimit"
ErrorTypeBalance ClineErrorType = "balance"
ErrorTypeUnknown ClineErrorType = "unknown"
)
// ClineError represents a parsed error from Cline API
type ClineError struct {
Message string `json:"message"`
Status int `json:"status"`
RequestID string `json:"request_id"`
Code interface{} `json:"code"` // Can be string or int
ModelID string `json:"modelId"`
ProviderID string `json:"providerId"`
Details map[string]interface{} `json:"details"`
}
// GetCodeString returns the code as a string regardless of its type
func (e *ClineError) GetCodeString() string {
if e == nil || e.Code == nil {
return ""
}
switch v := e.Code.(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprintf("%v", v)
}
}
// Rate limit patterns from webview
var rateLimitPatterns = []string{
"status code 429",
"rate limit",
"too many requests",
"quota exceeded",
"resource exhausted",
}
// ParseClineError parses a JSON error string into a ClineError
func ParseClineError(errorJSON string) (*ClineError, error) {
if errorJSON == "" {
return nil, nil
}
var err ClineError
if parseErr := json.Unmarshal([]byte(errorJSON), &err); parseErr != nil {
// If JSON parsing fails, create a simple error with the message
return &ClineError{
Message: errorJSON,
}, nil
}
return &err, nil
}
// GetErrorType determines the type of error based on code, status, and message
func (e *ClineError) GetErrorType() ClineErrorType {
if e == nil {
return ErrorTypeUnknown
}
// Check balance error first (most specific)
codeStr := e.GetCodeString()
if codeStr == "insufficient_credits" {
return ErrorTypeBalance
}
// Check auth errors
if codeStr == "ERR_BAD_REQUEST" || e.Status == 401 {
return ErrorTypeAuth
}
// Check for auth message
if strings.Contains(e.Message, "Authentication required") ||
strings.Contains(e.Message, "Invalid API key") ||
strings.Contains(e.Message, "Unauthorized") {
return ErrorTypeAuth
}
// Check rate limit patterns
messageLower := strings.ToLower(e.Message)
for _, pattern := range rateLimitPatterns {
if strings.Contains(messageLower, pattern) {
return ErrorTypeRateLimit
}
}
return ErrorTypeUnknown
}
// IsBalanceError returns true if this is a balance/credits error
func (e *ClineError) IsBalanceError() bool {
return e.GetErrorType() == ErrorTypeBalance
}
// IsAuthError returns true if this is an authentication error
func (e *ClineError) IsAuthError() bool {
return e.GetErrorType() == ErrorTypeAuth
}
// IsRateLimitError returns true if this is a rate limit error
func (e *ClineError) IsRateLimitError() bool {
return e.GetErrorType() == ErrorTypeRateLimit
}
// GetCurrentBalance returns the current balance if available
func (e *ClineError) GetCurrentBalance() *float64 {
if e == nil || e.Details == nil {
return nil
}
if balance, ok := e.Details["current_balance"].(float64); ok {
return &balance
}
return nil
}
// GetBuyCreditsURL returns the URL to buy credits if available
func (e *ClineError) GetBuyCreditsURL() string {
if e == nil || e.Details == nil {
return ""
}
if url, ok := e.Details["buy_credits_url"].(string); ok {
return url
}
return ""
}
// GetTotalSpent returns the total spent amount if available
func (e *ClineError) GetTotalSpent() *float64 {
if e == nil || e.Details == nil {
return nil
}
if spent, ok := e.Details["total_spent"].(float64); ok {
return &spent
}
return nil
}
// GetTotalPromotions returns the total promotions amount if available
func (e *ClineError) GetTotalPromotions() *float64 {
if e == nil || e.Details == nil {
return nil
}
if promos, ok := e.Details["total_promotions"].(float64); ok {
return &promos
}
return nil
}
// GetDetailMessage returns the detail message from error.details if available
func (e *ClineError) GetDetailMessage() string {
if e == nil || e.Details == nil {
return ""
}
if msg, ok := e.Details["message"].(string); ok {
return msg
}
return ""
}
-152
View File
@@ -1,152 +0,0 @@
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.
This command merges the provided settings with existing values, preserving
unspecified fields. Only the fields you explicitly set will be updated.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// 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 (server-side merge handles preserving existing values)
return configManager.UpdateSettings(ctx, settings, secrets)
},
}
cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
return cmd
}
-208
View File
@@ -1,208 +0,0 @@
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
}
-198
View File
@@ -1,198 +0,0 @@
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, enableNotifications, favorites)
fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor))
}
}
return nil
}
-27
View File
@@ -1,27 +0,0 @@
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")
}
-211
View File
@@ -1,211 +0,0 @@
package display
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/lipgloss"
)
// BannerInfo contains information to display in the session banner
type BannerInfo struct {
Version string
Provider string
ModelID string
Workdir string
Mode string
}
// RenderSessionBanner renders a nice banner showing version, model, and workspace info
func RenderSessionBanner(info BannerInfo) string {
// Bright white for title
titleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")). // Bright white
Bold(true)
// Dim gray for regular text (same as huh placeholder)
dimStyle := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"})
// Border color matches mode
borderColor := lipgloss.Color("3") // Yellow for plan
if info.Mode == "act" {
borderColor = lipgloss.Color("39") // Blue for act
}
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(1, 4)
var lines []string
// Format version with "v" prefix if it starts with a number
versionStr := info.Version
if len(versionStr) > 0 && versionStr[0] >= '0' && versionStr[0] <= '9' {
versionStr = "v" + versionStr
}
// First line: "cline cli vX.X.X" on left, "plan mode" on right
leftSide := titleStyle.Render("cline cli preview") + " " + dimStyle.Render(versionStr)
if info.Mode != "" {
modeColor := lipgloss.Color("3") // Yellow for plan
if info.Mode == "act" {
modeColor = lipgloss.Color("39") // Blue for act
}
modeStyle := lipgloss.NewStyle().Foreground(modeColor).Bold(true)
rightSide := modeStyle.Render(info.Mode + " mode")
// Calculate spacing to push mode to the right
// Assume a reasonable width (we'll adjust based on content)
lineWidth := 50
leftWidth := lipgloss.Width(leftSide)
rightWidth := lipgloss.Width(rightSide)
spacing := lineWidth - leftWidth - rightWidth
if spacing > 0 {
titleLine := leftSide + strings.Repeat(" ", spacing) + rightSide
lines = append(lines, titleLine)
} else {
// If too narrow, just put them on same line with a space
lines = append(lines, leftSide+" "+rightSide)
}
} else {
// No mode, just show title
lines = append(lines, leftSide)
}
// Model line - dim gray
if info.Provider != "" && info.ModelID != "" {
lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30)))
}
// Workspace line - dim gray
if info.Workdir != "" {
lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45)))
}
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
return boxStyle.Render(content)
}
// shortenPath shortens a filesystem path to fit within maxLen
func shortenPath(path string, maxLen int) string {
// Try to replace home directory with ~ (cross-platform)
if homeDir, err := os.UserHomeDir(); err == nil {
if strings.HasPrefix(path, homeDir) {
shortened := "~" + path[len(homeDir):]
// Always use ~ version if we can
path = shortened
}
}
if len(path) <= maxLen {
return path
}
// If still too long, show last few path components
if len(path) > maxLen {
parts := strings.Split(path, string(filepath.Separator))
if len(parts) > 2 {
// Show last 2-3 components
lastParts := parts[len(parts)-2:]
shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
if len(shortened) <= maxLen {
return shortened
}
}
}
// Last resort: truncate with ellipsis
if len(path) > maxLen {
return "..." + path[len(path)-maxLen+3:]
}
return path
}
// ExtractBannerInfoFromState extracts banner info from state JSON
func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) {
var state map[string]interface{}
if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err)
}
info := BannerInfo{
Version: version,
}
// Extract mode
if mode, ok := state["mode"].(string); ok {
info.Mode = mode
}
// Extract workspace roots
if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 {
if root, ok := workspaceRoots[0].(map[string]interface{}); ok {
if path, ok := root["path"].(string); ok {
info.Workdir = path
}
}
}
// Extract API configuration to get provider/model
if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok {
// Try common keys for provider and model (both camelCase and lowercase variants)
providerKeys := []string{"apiProvider", "api_provider"}
modelKeys := []string{"apiModelId", "api_model_id"}
// Try to extract provider
for _, key := range providerKeys {
if provider, ok := apiConfig[key].(string); ok && provider != "" {
info.Provider = provider
break
}
}
// Try to extract model ID
for _, key := range modelKeys {
if modelID, ok := apiConfig[key].(string); ok && modelID != "" {
info.ModelID = shortenModelID(modelID)
break
}
}
}
return info, nil
}
// shortenModelID shortens long model IDs for display
func shortenModelID(modelID string) string {
// Remove date suffixes only if they're at the end (e.g., -20241022)
// Check if the model ID ends with -YYYYMMDD pattern
if len(modelID) > 9 {
suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
if suffix[0] == '-' &&
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
// Verify all remaining chars are digits
allDigits := true
for _, c := range suffix[1:] {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if allDigits {
return modelID[:len(modelID)-9]
}
}
}
// If still too long, show first 40 chars
if len(modelID) > 40 {
return modelID[:37] + "..."
}
return modelID
}
-95
View File
@@ -1,95 +0,0 @@
package display
import (
"crypto/md5"
"fmt"
"sync"
"time"
"github.com/cline/cli/pkg/cli/types"
)
// MessageDeduplicator handles message deduplication to prevent duplicate displays
type MessageDeduplicator struct {
mu sync.RWMutex
seenMessages map[string]time.Time
maxAge time.Duration
cleanupTicker *time.Ticker
}
// NewMessageDeduplicator creates a new message deduplicator
func NewMessageDeduplicator() *MessageDeduplicator {
d := &MessageDeduplicator{
seenMessages: make(map[string]time.Time),
maxAge: 5 * time.Minute, // Keep messages for 5 minutes
cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
}
// Start cleanup goroutine
go d.cleanup()
return d
}
// IsDuplicate checks if a message is a duplicate
func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
d.mu.Lock()
defer d.mu.Unlock()
// Create a hash of the message content
hash := d.hashMessage(msg)
// Check if we've seen this message recently
if lastSeen, exists := d.seenMessages[hash]; exists {
// If we've seen it within the last few seconds, it's a duplicate
if time.Since(lastSeen) < 2*time.Second {
return true
}
}
// Mark this message as seen
d.seenMessages[hash] = time.Now()
return false
}
// hashMessage creates a hash of the message for deduplication
func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
// Create a hash based on message content, type, and timestamp
content := fmt.Sprintf("%s|%s|%s|%d",
string(msg.Type),
msg.Say,
msg.Ask,
msg.Timestamp)
// For partial messages, include the text content in the hash
if msg.Partial {
content += "|" + msg.Text
}
hash := md5.Sum([]byte(content))
return fmt.Sprintf("%x", hash)
}
// cleanup removes old entries from the seen messages map
func (d *MessageDeduplicator) cleanup() {
for range d.cleanupTicker.C {
d.mu.Lock()
now := time.Now()
// Remove entries older than maxAge
for hash, timestamp := range d.seenMessages {
if now.Sub(timestamp) > d.maxAge {
delete(d.seenMessages, hash)
}
}
d.mu.Unlock()
}
}
// Stop stops the cleanup goroutine
func (d *MessageDeduplicator) Stop() {
if d.cleanupTicker != nil {
d.cleanupTicker.Stop()
}
}
-139
View File
@@ -1,139 +0,0 @@
package display
import (
"os"
"strconv"
"strings"
"fmt"
"github.com/charmbracelet/glamour"
"golang.org/x/term"
)
type MarkdownRenderer struct {
renderer *glamour.TermRenderer
width int
}
// i went back and forth on whether or not to enable word wrap
// setting line width to 0 enables the terminal to handle wrapping
// setting it to a terminal width enables glamour's word wrap
// the thing is, glamour's nice indentation looks really good, and
// won't work without glamour's word wrap - if you use the terminal's
// word wrap, the indentation looks weird so you have to turn it off
// and everything will be right next to the left margin
// but if you DO use glamours word wrap, it also means if you resize the terminal,
// it will scuff everything. but given that this is the case for the input anyway,
// i figure we just make things as beautiful as possible
// and if you resize the terminal, you'll learn real quick.
// anyway, you can set this to true or false to experiment
const USETERMINALWORDWRAP = true
// seems like a reliable way to check for terminals
// for now i'm keeping everything as auto
// eventually we can define a custom glamour style for ghostty / iterm
// https://github.com/charmbracelet/glamour/blob/master/styles/README.md)
func detectTerminalTheme() string {
switch os.Getenv("TERM_PROGRAM") {
case "iTerm.app", "Ghostty":
return "dark"
}
if os.Getenv("GHOSTTY_VERSION") != "" {
return "dark"
}
return "dark"
}
func glamourStyleJSON(terminalWrap bool) string {
const tmpl = `{
"document": {
"block_prefix": "\n",
"block_suffix": "\n",
"color": "252",
"margin": %s
},
"code_block": {
"margin": 0
}
}`
if terminalWrap {
return fmt.Sprintf(tmpl, "0")
}
return fmt.Sprintf(tmpl, "2")
}
func NewMarkdownRenderer() (*MarkdownRenderer, error) {
var wordWrap int
if USETERMINALWORDWRAP {
// terminal handles wrapping -> disable glamour wrap
wordWrap = 0
} else {
// glamour handles wrapping -> set to current width
wordWrap = terminalWidthOr(0)
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins
glamour.WithWordWrap(wordWrap),
glamour.WithPreservedNewLines(),
)
if err != nil {
return nil, err
}
return &MarkdownRenderer{
renderer: r,
width: 0, // Unlimited width
}, nil
}
// terminalWidthOr returns the terminal width or the provided fallback.
// It first tries term.GetSize, then falls back to $COLUMNS if set.
func terminalWidthOr(fallback int) int {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
return w
}
if cols := os.Getenv("COLUMNS"); cols != "" {
if n, err := strconv.Atoi(cols); err == nil && n > 0 {
return n
}
}
return fallback
}
// NewMarkdownRendererWithWidth creates a markdown renderer with a specific width.
// Useful for tables and other content that should fit within terminal bounds.
func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) {
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(detectTerminalTheme()),
glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(false))),
glamour.WithWordWrap(width),
glamour.WithPreservedNewLines(),
)
if err != nil {
return nil, err
}
return &MarkdownRenderer{renderer: r, width: width}, nil
}
// NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width.
// Falls back to 120 if terminal width cannot be determined.
func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) {
width := terminalWidthOr(120)
return NewMarkdownRendererWithWidth(width)
}
func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
rendered, err := mr.renderer.Render(markdown)
if err != nil {
return "", err
}
return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil
}
-304
View File
@@ -1,304 +0,0 @@
package display
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/grpc-go/cline"
)
type Renderer struct {
typewriter *TypewriterPrinter
mdRenderer *MarkdownRenderer
outputFormat string
// Lipgloss styles that respect outputFormat
dimStyle lipgloss.Style
greenStyle lipgloss.Style
redStyle lipgloss.Style
yellowStyle lipgloss.Style
blueStyle lipgloss.Style
whiteStyle lipgloss.Style
boldStyle lipgloss.Style
successStyle lipgloss.Style
}
func NewRenderer(outputFormat string) *Renderer {
mdRenderer, err := NewMarkdownRenderer()
if err != nil {
mdRenderer = nil
}
r := &Renderer{
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
mdRenderer: mdRenderer,
outputFormat: outputFormat,
}
// Initialize lipgloss styles (will respect the global color profile)
r.dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
r.greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
r.redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
r.yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
r.blueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
r.whiteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7"))
r.boldStyle = lipgloss.NewStyle().Bold(true)
r.successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
return r
}
func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
if text == "" {
return nil
}
clean := r.sanitizeText(text)
if clean == "" {
return nil
}
if newline {
output.Printf("%s: %s\n", prefix, clean)
} else {
output.Printf("%s: %s", prefix, clean)
}
return nil
}
// formatNumber formats numbers with k/m abbreviations
func formatNumber(n int) string {
if n >= 1000000 {
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
} else if n >= 1000 {
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
}
return fmt.Sprintf("%d", n)
}
// formatUsageInfo formats token usage information (extracted from RenderAPI)
func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string {
parts := make([]string, 0, 4)
if tokensIn != 0 {
parts = append(parts, fmt.Sprintf("↑ %s", formatNumber(tokensIn)))
}
if tokensOut != 0 {
parts = append(parts, fmt.Sprintf("↓ %s", formatNumber(tokensOut)))
}
if cacheReads != 0 {
parts = append(parts, fmt.Sprintf("→ %s", formatNumber(cacheReads)))
}
if cacheWrites != 0 {
parts = append(parts, fmt.Sprintf("← %s", formatNumber(cacheWrites)))
}
if len(parts) == 0 {
return fmt.Sprintf("$%.4f", cost)
}
return fmt.Sprintf("%s $%.4f", strings.Join(parts, " "), cost)
}
func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error {
if apiInfo.Cost >= 0 {
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
rendered := r.RenderMarkdown(markdown)
output.Print(rendered)
} else {
// honestly i see no point in showing "### API processing request" here...
// markdown := fmt.Sprintf("## API %s", status)
// rendered := r.RenderMarkdown(markdown)
// output.Printf("\n%s\n", rendered)
}
return nil
}
func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts)
if delaySec > 0 {
message += fmt.Sprintf(" in %d seconds", delaySec)
}
message += "..."
r.typewriter.PrintMessageLine("API INFO", message)
return nil
}
func (r *Renderer) RenderTaskCancelled() error {
markdown := "## Task cancelled"
rendered := r.RenderMarkdown(markdown)
output.Printf("\n%s\n", rendered)
return nil
}
// RenderTaskList displays task history with improved formatting
func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
const maxTasks = 20
startIndex := 0
if len(tasks) > maxTasks {
startIndex = len(tasks) - maxTasks
}
recentTasks := tasks[startIndex:]
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
for i, taskItem := range recentTasks {
r.typewriter.PrintfLn("Task ID: %s", taskItem.Id)
description := taskItem.Task
if len(description) > 1000 {
description = description[:1000] + "..."
}
r.typewriter.PrintfLn("Message: %s", description)
usageInfo := r.formatUsageInfo(int(taskItem.TokensIn), int(taskItem.TokensOut), int(taskItem.CacheReads), int(taskItem.CacheWrites), taskItem.TotalCost)
r.typewriter.PrintfLn("Usage : %s", usageInfo)
// Single space between tasks (except last)
if i < len(recentTasks)-1 {
r.typewriter.PrintfLn("")
}
}
return nil
}
func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
if global.Config.Verbose {
message := fmt.Sprintf(format, args...)
r.typewriter.PrintMessageLine("[DEBUG]", message)
}
return nil
}
func (r *Renderer) ClearLine() {
output.Print("\r\033[K")
}
func (r *Renderer) MoveCursorUp(n int) {
output.Printf("\033[%dA", n)
}
func (r *Renderer) sanitizeText(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
// Remove control characters and escape sequences
var result strings.Builder
for _, r := range text {
// Keep printable characters, spaces, tabs, and newlines
if r >= 32 || r == '\t' || r == '\n' || r == '\r' {
result.WriteRune(r)
}
// Skip control characters (0-31 except tab, newline, carriage return)
}
return result.String()
}
func (r *Renderer) SetTypewriterEnabled(enabled bool) {
r.typewriter.SetEnabled(enabled)
}
func (r *Renderer) IsTypewriterEnabled() bool {
return r.typewriter.IsEnabled()
}
func (r *Renderer) SetTypewriterSpeed(multiplier float64) {
r.typewriter.SetSpeed(multiplier)
}
func (r *Renderer) GetTypewriter() *TypewriterPrinter {
return r.typewriter
}
func (r *Renderer) GetMdRenderer() *MarkdownRenderer {
return r.mdRenderer
}
// RenderMarkdown renders markdown text to terminal format with ANSI codes
// Falls back to plaintext if markdown rendering is unavailable or fails
// Respects output format - skips rendering in plain mode or non-TTY contexts
func (r *Renderer) RenderMarkdown(markdown string) string {
// Skip markdown rendering if:
// 1. Output format is explicitly "plain"
// 2. Not in a TTY (piped output, file redirect, CI, etc.)
if r.outputFormat == "plain" || !isTTY() {
return markdown
}
if r.mdRenderer == nil {
return markdown
}
rendered, err := r.mdRenderer.Render(markdown)
if err != nil {
return markdown
}
return rendered
}
// Lipgloss-based color rendering methods
// These automatically respect the output format via lipgloss color profile
// Dim renders text in dim gray (bright black)
func (r *Renderer) Dim(text string) string {
return r.dimStyle.Render(text)
}
// Green renders text in green
func (r *Renderer) Green(text string) string {
return r.greenStyle.Render(text)
}
// Red renders text in red
func (r *Renderer) Red(text string) string {
return r.redStyle.Render(text)
}
// Yellow renders text in yellow
func (r *Renderer) Yellow(text string) string {
return r.yellowStyle.Render(text)
}
// Blue renders text in 256-color blue (index 39)
func (r *Renderer) Blue(text string) string {
return r.blueStyle.Render(text)
}
// White renders text in white
func (r *Renderer) White(text string) string {
return r.whiteStyle.Render(text)
}
// Bold renders text in bold
func (r *Renderer) Bold(text string) string {
return r.boldStyle.Render(text)
}
// Success renders text in green with bold
func (r *Renderer) Success(text string) string {
return r.successStyle.Render(text)
}
// SuccessWithCheckmark renders text in green with bold and a checkmark prefix
func (r *Renderer) SuccessWithCheckmark(text string) string {
return r.Success("✓ " + text)
}
// ErrorWithX renders text in red with an X prefix
func (r *Renderer) ErrorWithX(text string) string {
return r.Red("✗ " + text)
}
-212
View File
@@ -1,212 +0,0 @@
package display
import (
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
)
type StreamingSegment struct {
mu sync.Mutex
sayType string
prefix string
buffer strings.Builder
frozen bool
mdRenderer *MarkdownRenderer
toolRenderer *ToolRenderer
shouldMarkdown bool
outputFormat string
msg *types.ClineMessage
toolParser *ToolResultParser
}
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
ss := &StreamingSegment{
sayType: sayType,
prefix: prefix,
mdRenderer: mdRenderer,
toolRenderer: NewToolRenderer(mdRenderer, outputFormat),
shouldMarkdown: shouldMarkdown,
outputFormat: outputFormat,
msg: msg,
toolParser: NewToolResultParser(mdRenderer),
}
// Render rich header immediately when creating segment (if in rich mode and TTY)
if shouldMarkdown && outputFormat != "plain" && isTTY() {
header := ss.generateRichHeader()
rendered, _ := mdRenderer.Render(header)
output.Println("")
output.Print(rendered)
}
return ss
}
func (ss *StreamingSegment) AppendText(text string) {
ss.mu.Lock()
defer ss.mu.Unlock()
if ss.frozen {
return
}
// Replace buffer with FULL text - msg.Text contains complete accumulated content
ss.buffer.Reset()
ss.buffer.WriteString(text)
// No rendering during streaming - we'll render once on Freeze()
}
func (ss *StreamingSegment) Freeze() {
ss.mu.Lock()
defer ss.mu.Unlock()
if ss.frozen {
return
}
ss.frozen = true
currentBuffer := ss.buffer.String()
// Render and print the final markdown
ss.renderFinal(currentBuffer)
}
func (ss *StreamingSegment) renderFinal(currentBuffer string) {
var bodyContent string
// Use ToolRenderer for all body rendering to centralize logic
if ss.sayType == "ask" {
// Handle ASK messages
if ss.msg.Ask == string(types.AskTypeTool) {
// Tool approval: use ToolRenderer for body
var tool types.ToolMessage
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
// For approval requests in streaming, use the preview method
bodyContent = ss.toolRenderer.GenerateToolContentPreview(&tool)
}
} else if ss.msg.Ask == string(types.AskTypeFollowup) {
// Followup question: use ToolRenderer
bodyContent = ss.toolRenderer.GenerateAskFollowupBody(currentBuffer)
} else if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
// Plan mode respond: use ToolRenderer
bodyContent = ss.toolRenderer.GeneratePlanModeRespondBody(currentBuffer)
} else if ss.msg.Ask == string(types.AskTypeCommand) {
// Command approval: no body needed - header shows command, output shown separately later
bodyContent = ""
} else {
// For other ask types, render as-is
bodyContent = currentBuffer
}
} else if ss.sayType == string(types.SayTypeTool) {
// Tool execution (SAY): use ToolRenderer for body
var tool types.ToolMessage
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool)
}
} else if ss.sayType == string(types.SayTypeCommand) {
// Command output
bodyContent = "```shell\n" + currentBuffer + "\n```"
// Render markdown only in rich mode and TTY
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
rendered, err := ss.mdRenderer.Render(bodyContent)
if err == nil {
bodyContent = rendered
}
}
} else {
// For other types (reasoning, text, etc.), render markdown as-is
if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() {
rendered, err := ss.mdRenderer.Render(currentBuffer)
if err == nil {
bodyContent = rendered
} else {
bodyContent = currentBuffer
}
} else {
bodyContent = currentBuffer
}
}
// Print the body content
if bodyContent != "" {
if !strings.HasSuffix(bodyContent, "\n") {
output.Print(bodyContent)
output.Println("")
} else {
output.Print(bodyContent)
}
}
}
// generateRichHeader generates a contextual header for the segment
func (ss *StreamingSegment) generateRichHeader() string {
switch ss.sayType {
case string(types.SayTypeReasoning):
return "### Cline is thinking\n"
case string(types.SayTypeText):
return "### Cline responds\n"
case string(types.SayTypeCompletionResult):
return "### Task completed\n"
case string(types.SayTypeTool):
return ss.generateToolHeader()
case "ask":
// Check the specific ask type
if ss.msg.Ask == string(types.AskTypePlanModeRespond) {
return ss.toolRenderer.GeneratePlanModeRespondHeader()
}
// For tool approvals, show proper tool header
if ss.msg.Ask == string(types.AskTypeTool) {
var tool types.ToolMessage
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err == nil {
// Use ToolRenderer for approval header with "wants to" verbs
return ss.toolRenderer.RenderToolApprovalHeader(&tool)
}
}
// For command approvals, show command header
if ss.msg.Ask == string(types.AskTypeCommand) {
command := strings.TrimSpace(ss.msg.Text)
if strings.HasSuffix(command, "REQ_APP") {
command = strings.TrimSuffix(command, "REQ_APP")
command = strings.TrimSpace(command)
}
return fmt.Sprintf("### Cline wants to run `%s`\n", command)
}
// For followup questions, show question header
if ss.msg.Ask == string(types.AskTypeFollowup) {
return ss.toolRenderer.GenerateAskFollowupHeader()
}
// For other ask types, show generic message
return fmt.Sprintf("### Cline is asking (%s)\n", ss.msg.Ask)
default:
return fmt.Sprintf("### %s\n", ss.prefix)
}
}
// generateToolHeader generates a contextual header for tool operations
func (ss *StreamingSegment) generateToolHeader() string {
// Parse tool JSON from message text
var tool types.ToolMessage
if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil {
return "### Tool operation\n"
}
// Use unified ToolRenderer for header
return ss.toolRenderer.RenderToolExecutionHeader(&tool)
}
-134
View File
@@ -1,134 +0,0 @@
package display
import (
"fmt"
"strings"
"sync"
"github.com/cline/cli/pkg/cli/types"
)
// StreamingDisplay manages streaming message display with deduplication
type StreamingDisplay struct {
mu sync.RWMutex
state *types.ConversationState
renderer *Renderer
dedupe *MessageDeduplicator
activeSegment *StreamingSegment
mdRenderer *MarkdownRenderer
}
// NewStreamingDisplay creates a new streaming display manager
func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay {
mdRenderer, err := NewMarkdownRenderer()
if err != nil {
panic(fmt.Sprintf("Failed to initialize markdown renderer: %v", err))
}
return &StreamingDisplay{
state: state,
renderer: renderer,
dedupe: NewMessageDeduplicator(),
mdRenderer: mdRenderer,
}
}
// HandlePartialMessage processes partial messages with streaming support
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check for deduplication
if s.dedupe.IsDuplicate(msg) {
return nil
}
// Segment-based header-only streaming
// Partial stream only shows headers immediately, state stream will handle content bodies
sayType := msg.Say
if msg.Type == types.MessageTypeAsk {
sayType = "ask"
}
// Detect segment boundary
if s.activeSegment != nil && s.activeSegment.sayType != sayType {
// Just cleanup, don't freeze (no body to print)
s.activeSegment = nil
}
// On first partial message for a new segment type, create segment (prints header)
if s.activeSegment == nil && msg.Partial {
shouldMd := s.shouldRenderMarkdown(sayType)
prefix := s.getPrefix(sayType)
// NewStreamingSegment prints the header immediately
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
// Header printed, done - don't append text or freeze
return nil
}
// For subsequent partial messages, do nothing (header already shown)
if msg.Partial {
return nil
}
// When message is complete (partial=false), render the content body
if s.activeSegment != nil {
// Had an active segment from partial messages - freeze to render body
s.activeSegment.AppendText(msg.Text)
s.activeSegment.Freeze()
s.activeSegment = nil
} else if !msg.Partial {
// Message arrived complete without partial phase - create segment and render immediately
shouldMd := s.shouldRenderMarkdown(sayType)
prefix := s.getPrefix(sayType)
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
segment.AppendText(msg.Text)
segment.Freeze()
}
return nil
}
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
switch sayType {
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
return true
default:
return false
}
}
func (s *StreamingDisplay) getPrefix(sayType string) string {
switch sayType {
case string(types.SayTypeReasoning):
return "THINKING"
case string(types.SayTypeText):
return "CLINE"
case string(types.SayTypeCompletionResult):
return "RESULT"
case "ask":
return "ASK"
case string(types.SayTypeCommand):
return "TERMINAL"
default:
return strings.ToUpper(sayType)
}
}
func (s *StreamingDisplay) FreezeActiveSegment() {
s.mu.Lock()
defer s.mu.Unlock()
if s.activeSegment != nil {
s.activeSegment.Freeze()
s.activeSegment = nil
}
}
// Cleanup cleans up streaming display resources
func (s *StreamingDisplay) Cleanup() {
s.FreezeActiveSegment()
if s.dedupe != nil {
s.dedupe.Stop()
}
}
-269
View File
@@ -1,269 +0,0 @@
package display
import (
"fmt"
"strings"
"github.com/cline/cli/pkg/cli/clerror"
)
// ErrorSeverity represents the severity level of an error
type ErrorSeverity string
const (
SeverityCritical ErrorSeverity = "critical"
SeverityWarning ErrorSeverity = "warning"
SeverityInfo ErrorSeverity = "info"
)
// SystemMessageRenderer handles rendering of system messages (errors, warnings, info)
type SystemMessageRenderer struct {
renderer *Renderer
mdRenderer *MarkdownRenderer
outputFormat string
}
// NewSystemMessageRenderer creates a new system message renderer
func NewSystemMessageRenderer(renderer *Renderer, mdRenderer *MarkdownRenderer, outputFormat string) *SystemMessageRenderer {
return &SystemMessageRenderer{
renderer: renderer,
mdRenderer: mdRenderer,
outputFormat: outputFormat,
}
}
// RenderError renders a beautiful error message with optional details
func (sr *SystemMessageRenderer) RenderError(severity ErrorSeverity, title, body string, details map[string]string) error {
var colorMarkdown string
switch severity {
case SeverityCritical:
colorMarkdown = "**[ERROR]**"
case SeverityWarning:
colorMarkdown = "**[WARNING]**"
case SeverityInfo:
colorMarkdown = "**[INFO]**"
}
// Build the error message in markdown
var parts []string
// Header
header := fmt.Sprintf("### %s %s", colorMarkdown, title)
parts = append(parts, header)
// Body
if body != "" {
parts = append(parts, "", body)
}
// Details
if len(details) > 0 {
parts = append(parts, "", "**Details:**")
for key, value := range details {
parts = append(parts, fmt.Sprintf("- %s: `%s`", key, value))
}
}
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderBalanceError renders a special balance/credits error with helpful info
func (sr *SystemMessageRenderer) RenderBalanceError(err *clerror.ClineError) error {
var parts []string
// Header
parts = append(parts, "### **[ERROR]** Credit Limit Reached")
parts = append(parts, "")
// Message - prefer detail message from error.details, fallback to main message
message := err.Message
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
message = detailMsg
}
parts = append(parts, message)
parts = append(parts, "")
// Account Balance section
parts = append(parts, "**Account Balance:**")
// Current balance
if balance := err.GetCurrentBalance(); balance != nil {
parts = append(parts, fmt.Sprintf("- Current Balance: **$%.2f**", *balance))
}
// Total spent
if spent := err.GetTotalSpent(); spent != nil {
parts = append(parts, fmt.Sprintf("- Total Spent: $%.2f", *spent))
}
// Promotions applied
if promos := err.GetTotalPromotions(); promos != nil {
parts = append(parts, fmt.Sprintf("- Promotions Applied: $%.2f", *promos))
}
parts = append(parts, "")
// Buy credits link
if url := err.GetBuyCreditsURL(); url != "" {
parts = append(parts, fmt.Sprintf("**→ Buy credits:** %s", url))
} else {
// Fallback - show both personal and org URLs
parts = append(parts, "**→ Buy credits:**")
parts = append(parts, " - Personal: https://app.cline.bot/dashboard/account?tab=credits")
parts = append(parts, " - Organization: https://app.cline.bot/dashboard/organization?tab=credits")
}
// Request ID (less prominent at the end)
if err.RequestID != "" {
parts = append(parts, "")
parts = append(parts, fmt.Sprintf("*Request ID: %s*", err.RequestID))
}
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderAuthError renders an authentication error with helpful guidance
func (sr *SystemMessageRenderer) RenderAuthError(err *clerror.ClineError) error {
var parts []string
// Header
parts = append(parts, "### **[ERROR]** Authentication Failed")
parts = append(parts, "")
// Message - prefer detail message if available
message := err.Message
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
message = detailMsg
}
parts = append(parts, message)
parts = append(parts, "")
// Guidance
parts = append(parts, "**Next Steps:**")
parts = append(parts, "- Check your API key configuration")
parts = append(parts, "- Run `cline auth` to authenticate")
parts = append(parts, "- Verify your account status at https://app.cline.bot")
// Request ID
if err.RequestID != "" {
parts = append(parts, "")
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
}
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderRateLimitError renders a rate limit error with request ID
func (sr *SystemMessageRenderer) RenderRateLimitError(err *clerror.ClineError) error {
var parts []string
// Header
parts = append(parts, "### **[WARNING]** Rate Limit Reached")
parts = append(parts, "")
// Message - prefer detail message if available
message := err.Message
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
message = detailMsg
}
parts = append(parts, message)
parts = append(parts, "")
// Guidance
parts = append(parts, "The API will automatically retry this request.")
// Request ID
if err.RequestID != "" {
parts = append(parts, "")
parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID))
}
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderAPIError renders a generic API error with all available details
func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error {
var parts []string
// Header
parts = append(parts, "### **[ERROR]** API Request Failed")
parts = append(parts, "")
// Message - prefer detail message if available
message := err.Message
if detailMsg := err.GetDetailMessage(); detailMsg != "" {
message = detailMsg
}
parts = append(parts, message)
// Details
var details []string
if err.RequestID != "" {
details = append(details, fmt.Sprintf("- Request ID: `%s`", err.RequestID))
}
if code := err.GetCodeString(); code != "" {
details = append(details, fmt.Sprintf("- Error Code: `%s`", code))
}
if err.Status > 0 {
details = append(details, fmt.Sprintf("- HTTP Status: `%d`", err.Status))
}
if err.ModelID != "" {
details = append(details, fmt.Sprintf("- Model: `%s`", err.ModelID))
}
if err.ProviderID != "" {
details = append(details, fmt.Sprintf("- Provider: `%s`", err.ProviderID))
}
if len(details) > 0 {
parts = append(parts, "")
parts = append(parts, "**Details:**")
parts = append(parts, strings.Join(details, "\n"))
}
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderWarning renders a warning message
func (sr *SystemMessageRenderer) RenderWarning(title, message string) error {
markdown := fmt.Sprintf("### **[WARNING]** %s\n\n%s", title, message)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderInfo renders an info message
func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
markdown := fmt.Sprintf("### **[INFO]** %s\n\n%s", title, message)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
return nil
}
// RenderCheckpoint renders a checkpoint creation message
func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error {
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf(rendered)
return nil
}
-455
View File
@@ -1,455 +0,0 @@
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.ToolTypeFileDeleted):
if verbTense == "wants to" {
action = "wants to delete"
} else {
action = "is deleting"
}
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), string(types.ToolTypeFileDeleted):
// 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),
string(types.ToolTypeFileDeleted):
// readFile: show header only, no body
return ""
case string(types.ToolTypeListFilesTopLevel),
string(types.ToolTypeListFilesRecursive),
string(types.ToolTypeListCodeDefinitionNames),
string(types.ToolTypeSearchFiles),
string(types.ToolTypeWebFetch):
// Use parser for structured output
preview := toolParser.ParseToolResult(tool)
return tr.renderMarkdown(preview)
case string(types.ToolTypeEditedExistingFile):
// Show the diff
diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content)
return tr.renderMarkdown(diffMarkdown)
case string(types.ToolTypeNewFileCreated):
// Show file content preview
preview := strings.TrimSpace(tool.Content)
if len(preview) > 1000 {
preview = preview[:1000] + "..."
}
contentMd := fmt.Sprintf("```\n%s\n```", preview)
return tr.renderMarkdown(contentMd)
default:
// For unknown tools, show content as-is
if len(tool.Content) > 500 {
return tool.Content[:500] + "..."
}
return tool.Content
}
}
// RenderCommandApprovalRequest renders a command approval request
func (tr *ToolRenderer) RenderCommandApprovalRequest(command string, autoApprovalConflict bool) string {
var output strings.Builder
// Clean command
command = strings.TrimSpace(command)
if strings.HasSuffix(command, "REQ_APP") {
command = strings.TrimSuffix(command, "REQ_APP")
command = strings.TrimSpace(command)
autoApprovalConflict = true
}
// Generate header
header := fmt.Sprintf("### Cline wants to run `%s`", command)
rendered := tr.renderMarkdown(header)
output.WriteString(rendered)
output.WriteString("\n")
// Show command in code block
cmdBlock := fmt.Sprintf("```shell\n%s\n```", command)
cmdRendered := tr.renderMarkdown(cmdBlock)
output.WriteString("\n")
output.WriteString(cmdRendered)
// Add warning if needed
if autoApprovalConflict {
output.WriteString("\nWARNING: The model has determined this command requires explicit approval.\n")
}
return output.String()
}
// RenderCommandExecution renders a command execution announcement
func (tr *ToolRenderer) RenderCommandExecution(command string) string {
command = strings.TrimSpace(command)
header := fmt.Sprintf("### Cline is running `%s`", command)
rendered := tr.renderMarkdown(header)
return "\n" + rendered + "\n"
}
// RenderCommandOutput renders command output
func (tr *ToolRenderer) RenderCommandOutput(output string) string {
var result strings.Builder
header := "### Terminal output"
rendered := tr.renderMarkdown(header)
result.WriteString("\n")
result.WriteString(rendered)
result.WriteString("\n\n")
// Show output in code block
outputBlock := fmt.Sprintf("```\n%s\n```", strings.TrimSpace(output))
outputRendered := tr.renderMarkdown(outputBlock)
result.WriteString(outputRendered)
result.WriteString("\n")
return result.String()
}
// RenderUserResponse renders user approval/rejection feedback
func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string {
var symbol, status string
if approved {
symbol = "✓"
status = "Approved"
} else {
symbol = "✗"
status = "Rejected"
}
if feedback != "" {
return fmt.Sprintf("%s %s with feedback: %s\n", symbol, status, feedback)
}
return fmt.Sprintf("%s %s\n", symbol, status)
}
// renderMarkdown renders markdown if not in plain mode and in a TTY
func (tr *ToolRenderer) renderMarkdown(markdown string) string {
// Skip markdown rendering if plain mode or not in TTY
if tr.outputFormat == "plain" || !isTTY() {
return markdown
}
if tr.mdRenderer == nil {
return markdown
}
rendered, err := tr.mdRenderer.Render(markdown)
if err != nil {
return markdown
}
return rendered
}
// GenerateAskFollowupHeader generates the header for followup questions
func (tr *ToolRenderer) GenerateAskFollowupHeader() string {
return "### Cline has a question\n"
}
// GenerateAskFollowupBody generates the body content for followup questions
func (tr *ToolRenderer) GenerateAskFollowupBody(messageText string) string {
var question string
var options []string
// Try to parse as JSON
var askData types.AskData
if err := json.Unmarshal([]byte(messageText), &askData); err == nil {
question = askData.Question
options = askData.Options
} else {
question = messageText
}
if question == "" {
return ""
}
// Build the body
var body strings.Builder
// Render the question
rendered := tr.renderMarkdown(question)
body.WriteString(rendered)
// Add options if available
if len(options) > 0 {
body.WriteString("\n\nOptions:\n")
for i, option := range options {
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
}
}
return body.String()
}
// GeneratePlanModeRespondHeader generates the header for plan mode responses
func (tr *ToolRenderer) GeneratePlanModeRespondHeader() string {
return "### Cline has a plan\n"
}
// GeneratePlanModeRespondBody generates the body content for plan mode responses
func (tr *ToolRenderer) GeneratePlanModeRespondBody(messageText string) string {
var response string
var options []string
// Try to parse as JSON
type PlanModeResponse struct {
Response string `json:"response"`
Options []string `json:"options,omitempty"`
}
var planData PlanModeResponse
if err := json.Unmarshal([]byte(messageText), &planData); err == nil {
response = planData.Response
options = planData.Options
} else {
response = messageText
}
if response == "" {
return ""
}
// Build the body
var body strings.Builder
// Render the response
rendered := tr.renderMarkdown(response)
body.WriteString(rendered)
// Add options if available
if len(options) > 0 {
body.WriteString("\n\nOptions:\n")
for i, option := range options {
body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option))
}
}
return body.String()
}
-371
View File
@@ -1,371 +0,0 @@
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
}
}
-210
View File
@@ -1,210 +0,0 @@
package display
import (
"fmt"
"os"
"time"
)
// TypewriterConfig holds configuration for the typewriter effect
type TypewriterConfig struct {
BaseDelay time.Duration // Base delay between characters
FastDelay time.Duration // Faster delay for common characters
SlowDelay time.Duration // Slower delay for punctuation
PauseDelay time.Duration // Pause after sentences
Enabled bool // Whether typewriter effect is enabled
RandomFactor float64 // Randomness factor (0.0 to 1.0)
}
// DefaultTypewriterConfig returns the default typewriter configuration
func DefaultTypewriterConfig() *TypewriterConfig {
return &TypewriterConfig{
BaseDelay: 15 * time.Millisecond,
FastDelay: 8 * time.Millisecond,
SlowDelay: 25 * time.Millisecond,
PauseDelay: 150 * time.Millisecond,
Enabled: false,
RandomFactor: 0.3,
}
}
// TypewriterPrinter handles typewriter-style output
type TypewriterPrinter struct {
config *TypewriterConfig
}
// NewTypewriterPrinter creates a new typewriter printer
func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
if config == nil {
config = DefaultTypewriterConfig()
}
return &TypewriterPrinter{
config: config,
}
}
// Print prints text with typewriter effect
func (tp *TypewriterPrinter) Print(text string) {
if !tp.config.Enabled {
fmt.Print(text)
return
}
tp.typewriterPrint(text)
}
// Printf prints formatted text with typewriter effect
func (tp *TypewriterPrinter) Printf(format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
tp.Print(text)
}
// Println prints text with typewriter effect and adds a newline
func (tp *TypewriterPrinter) Println(text string) {
tp.Print(text + "\n")
}
// PrintfLn prints formatted text with typewriter effect and adds a newline
func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
text := fmt.Sprintf(format, args...)
tp.Println(text)
}
// PrintInstant prints text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintInstant(text string) {
fmt.Print(text)
}
// PrintfInstant prints formatted text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
fmt.Printf(format, args...)
}
// typewriterPrint displays text with a typewriter animation effect
func (tp *TypewriterPrinter) typewriterPrint(text string) {
// Convert string to runes to handle Unicode properly
runes := []rune(text)
for i, r := range runes {
// Print the character
fmt.Print(string(r))
os.Stdout.Sync() // Force immediate output
// Don't add delay after the last character
if i == len(runes)-1 {
break
}
// Determine delay based on character type
delay := tp.getDelayForCharacter(r, i)
// Sleep for the calculated delay
time.Sleep(delay)
}
}
// getDelayForCharacter returns the appropriate delay for a character
func (tp *TypewriterPrinter) getDelayForCharacter(r rune, position int) time.Duration {
var baseDelay time.Duration
switch {
case r == '.' || r == '!' || r == '?':
// Longer pause after sentence endings
baseDelay = tp.config.PauseDelay
case r == ',' || r == ';' || r == ':':
// Medium pause after punctuation
baseDelay = tp.config.SlowDelay
case r == ' ':
// Slightly faster for spaces
baseDelay = tp.config.FastDelay
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z':
// Fast for common letters
baseDelay = tp.config.FastDelay
case r == '\n':
// No delay for newlines
return 0
default:
// Base delay for other characters
baseDelay = tp.config.BaseDelay
}
// Add randomness to make it feel more natural
if tp.config.RandomFactor > 0 {
// Simple pseudo-random based on position to ensure consistency
randomFactor := 0.7 + (tp.config.RandomFactor * float64(position%7) / 6.0)
baseDelay = time.Duration(float64(baseDelay) * randomFactor)
}
return baseDelay
}
// SetEnabled enables or disables the typewriter effect
func (tp *TypewriterPrinter) SetEnabled(enabled bool) {
tp.config.Enabled = enabled
}
// IsEnabled returns whether the typewriter effect is enabled
func (tp *TypewriterPrinter) IsEnabled() bool {
return tp.config.Enabled
}
// SetSpeed adjusts the typewriter speed (multiplier: 0.1 = very slow, 1.0 = normal, 2.0 = fast)
func (tp *TypewriterPrinter) SetSpeed(multiplier float64) {
if multiplier <= 0 {
multiplier = 1.0
}
tp.config.BaseDelay = time.Duration(float64(15*time.Millisecond) / multiplier)
tp.config.FastDelay = time.Duration(float64(8*time.Millisecond) / multiplier)
tp.config.SlowDelay = time.Duration(float64(25*time.Millisecond) / multiplier)
tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier)
}
func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) {
tp.PrintfInstant("%s: ", prefix)
tp.Println(text)
}
// Global typewriter printer instance
var globalTypewriter = NewTypewriterPrinter(DefaultTypewriterConfig())
// Global convenience functions that use the global typewriter instance
// TypewriterPrint prints text with typewriter effect using the global instance
func TypewriterPrint(text string) {
globalTypewriter.Print(text)
}
// TypewriterPrintf prints formatted text with typewriter effect using the global instance
func TypewriterPrintf(format string, args ...interface{}) {
globalTypewriter.Printf(format, args...)
}
// TypewriterPrintln prints text with typewriter effect and newline using the global instance
func TypewriterPrintln(text string) {
globalTypewriter.Println(text)
}
// TypewriterPrintfLn prints formatted text with typewriter effect and newline using the global instance
func TypewriterPrintfLn(format string, args ...interface{}) {
globalTypewriter.PrintfLn(format, args...)
}
func TypewriterPrintMessageLine(prefix, text string) {
globalTypewriter.PrintMessageLine(prefix, text)
}
// SetGlobalTypewriterEnabled enables or disables the global typewriter effect
func SetGlobalTypewriterEnabled(enabled bool) {
globalTypewriter.SetEnabled(enabled)
}
// SetGlobalTypewriterSpeed sets the speed of the global typewriter effect
func SetGlobalTypewriterSpeed(multiplier float64) {
globalTypewriter.SetSpeed(multiplier)
}
// GetGlobalTypewriter returns the global typewriter instance
func GetGlobalTypewriter() *TypewriterPrinter {
return globalTypewriter
}
-65
View File
@@ -1,65 +0,0 @@
package cli
import (
"fmt"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/terminal"
"github.com/cline/cli/pkg/cli/updater"
"github.com/spf13/cobra"
)
// NewDoctorCommand creates the doctor command
func NewDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Aliases: []string{"d"},
Short: "Check system health and diagnose problems",
Long: `Check the health of your Cline CLI installation and diagnose problems.
Currently this command performs the following checks and fixes:
Terminal Configuration:
- Detects your terminal emulator (VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty)
- Configures shift+enter to insert newlines in multiline input
- Creates backups before modifying configuration files
- Supported terminals: VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty
- iTerm2 works by default, Terminal.app requires manual setup
CLI Updates:
- Checks npm registry for the latest version
- Automatically installs updates via npm if available
- Respects NO_AUTO_UPDATE environment variable
- Skipped in CI environments
Note: Future versions will include additional health checks for Node.js version,
npm availability, Cline Core connectivity, database integrity, and more.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctorChecks()
},
}
return cmd
}
// runDoctorChecks performs all doctor diagnostics and configuration
func runDoctorChecks() error {
renderer := display.NewRenderer(global.Config.OutputFormat)
fmt.Printf("\n%s\n\n", renderer.Bold("Cline Doctor - System Health Check"))
// Configure terminal keybindings (terminal.go prints its own status)
fmt.Printf("%s\n\n", renderer.Dim("━━━ Terminal Configuration ━━━"))
terminal.SetupKeyboardSync()
// Check for updates (updater.go prints its own status)
fmt.Printf("\n%s\n\n", renderer.Dim("━━━ CLI Updates ━━━"))
updater.CheckAndUpdateSync(global.Config.Verbose, true)
// Summary
fmt.Printf("\n%s\n", renderer.Dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
fmt.Printf("\n%s\n\n", renderer.SuccessWithCheckmark("Health check complete"))
return nil
}
-501
View File
@@ -1,501 +0,0 @@
package global
import (
"context"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"syscall"
"time"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
)
// ClineClients manages Cline instances using the new registry system
type ClineClients struct {
registry *ClientRegistry
}
// NewClineClients creates a new ClineClients instance
func NewClineClients(configPath string) *ClineClients {
registry := NewClientRegistry(configPath)
return &ClineClients{
registry: registry,
}
}
// Initialize performs cleanup of stale instances
func (c *ClineClients) Initialize(ctx context.Context) error {
// Clean up stale entries (direct SQLite operations)
_ = c.registry.CleanupStaleInstances(ctx)
return nil
}
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
// Find available ports
corePort, hostPort, err := common.FindAvailablePortPair()
if err != nil {
return nil, fmt.Errorf("failed to find available ports: %w", err)
}
if Config.Verbose {
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
}
// Start cline-host first
hostCmd, err := startClineHost(hostPort, corePort)
if err != nil {
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
// Start cline-core (it will register itself in SQLite locks database)
coreCmd, err := startClineCore(corePort, hostPort)
if err != nil {
// Clean up host process if core fails to start
if hostCmd != nil && hostCmd.Process != nil {
hostCmd.Process.Kill()
}
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
fullAddress := fmt.Sprintf("localhost:%d", corePort)
if Config.Verbose {
fmt.Println("Waiting for services to start and self-register in SQLite...")
}
// Use RetryOperation to wait for instance to be ready
var instance *common.CoreInstanceInfo
err = common.RetryOperation(12, 5*time.Second, func() error {
// Check if instance registered itself in SQLite
foundInstance, err := c.registry.GetInstance(fullAddress)
if err != nil || foundInstance == nil {
return fmt.Errorf("instance not found in registry: %v", err)
}
// Verify instance is healthy
if !common.IsInstanceHealthy(ctx, fullAddress) {
return fmt.Errorf("instance is registered but not healthy")
}
// Success - store the instance for return
instance = foundInstance
return nil
})
if err != nil {
// Clean up both processes on failure
if coreCmd != nil && coreCmd.Process != nil {
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
coreCmd.Process.Kill()
}
if hostCmd != nil && hostCmd.Process != nil {
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
hostCmd.Process.Kill()
}
return nil, fmt.Errorf("failed to start instance: %w", err)
}
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
}
// If this is the first instance, set it as default
instances := c.registry.ListInstances()
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
if Config.Verbose {
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
}
}
return instance, nil
}
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
// Find available host port (core port + 1000)
hostPort := corePort + 1000
coreAddress := fmt.Sprintf("localhost:%d", corePort)
// Check if the specified core port is available
if common.IsInstanceHealthy(ctx, coreAddress) {
return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort)
}
if Config.Verbose {
fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort)
}
// Start cline-host first
hostCmd, err := startClineHost(hostPort, corePort)
if err != nil {
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
// Start cline-core (it will register itself in SQLite locks database)
coreCmd, err := startClineCore(corePort, hostPort)
if err != nil {
// Clean up host process if core fails to start
if hostCmd != nil && hostCmd.Process != nil {
hostCmd.Process.Kill()
}
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
fullAddress := fmt.Sprintf("localhost:%d", corePort)
if Config.Verbose {
fmt.Println("Waiting for services to start and self-register in SQLite...")
}
// Use RetryOperation to wait for instance to be ready
var instance *common.CoreInstanceInfo
err = common.RetryOperation(12, 5*time.Second, func() error {
// Check if instance registered itself in SQLite
foundInstance, err := c.registry.GetInstance(fullAddress)
if err != nil || foundInstance == nil {
return fmt.Errorf("instance not found in registry: %v", err)
}
// Verify instance is healthy
if !common.IsInstanceHealthy(ctx, fullAddress) {
return fmt.Errorf("instance is registered but not healthy")
}
// Success - store the instance for return
instance = foundInstance
return nil
})
if err != nil {
// Clean up both processes on failure
if coreCmd != nil && coreCmd.Process != nil {
fmt.Printf("Cleaning up core process (PID: %d)\n", coreCmd.Process.Pid)
coreCmd.Process.Kill()
}
if hostCmd != nil && hostCmd.Process != nil {
fmt.Printf("Cleaning up host process (PID: %d)\n", hostCmd.Process.Pid)
hostCmd.Process.Kill()
}
return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err)
}
if Config.Verbose {
fmt.Println("Services started and registered successfully!")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
}
// If this is the first instance, set it as default
instances := c.registry.ListInstances()
if err := c.registry.EnsureDefaultInstance(instances); err != nil {
if Config.Verbose {
fmt.Printf("Warning: Failed to set default instance: %v\n", err)
}
}
return instance, nil
}
// GetRegistry returns the client registry
func (c *ClineClients) GetRegistry() *ClientRegistry {
return c.registry
}
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
// Expect host:port everywhere
normalized := address
if normalized == "" {
normalized = fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT)
}
// Check if instance already exists at this address
if c.registry.HasInstanceAtAddress(normalized) {
return nil
}
// Parse host:port
host, port, err := common.ParseHostPort(normalized)
if err != nil {
return fmt.Errorf("invalid address format %s", address)
}
// Use IPv6-compatible localhost detection
if common.IsLocalAddress(host) {
_, err := c.StartNewInstanceAtPort(ctx, port)
if err != nil {
return fmt.Errorf("failed to start new instance at %s: %w", normalized, err)
}
return nil
}
return fmt.Errorf("cannot start remote instance at %s", normalized)
}
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-host on port %d\n", hostPort)
}
// Get the directory where the cline binary is located
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
binDir := path.Dir(execPath)
clineHostPath := path.Join(binDir, "cline-host")
// Start the cline-host process
cmd := exec.Command(clineHostPath,
"--verbose",
"--port", fmt.Sprintf("%d", hostPort))
// Create logs directory in ~/.cline/logs
logsDir := path.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
logFilePath := path.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
}
return cmd, nil
}
// KillInstanceByAddress kills a Cline instance by its address
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
// Check if the instance exists in the registry
_, err := registry.GetInstance(address)
if err != nil {
return fmt.Errorf("instance %s not found in registry", address)
}
if Config.Verbose {
fmt.Printf("Killing instance: %s\n", address)
}
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
}
pid := int(processInfo.ProcessId)
if Config.Verbose {
fmt.Printf("Terminating process PID %d...\n", pid)
}
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to kill process %d: %w", pid, err)
}
// Wait for the instance to remove itself from registry
if Config.Verbose {
fmt.Printf("Waiting for instance to clean up registry entry...\n")
}
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second)
if !registry.HasInstanceAtAddress(address) {
if Config.Verbose {
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
}
// Update default instance if needed
instances, err := registry.ListInstancesCleaned(ctx)
if err == nil && len(instances) > 0 {
// ensureDefaultInstance logic will handle setting a new default
defaultInstance := registry.GetDefaultInstance()
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
}
}
}
}
}
return nil
}
}
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
}
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
}
// Get the executable path and resolve symlinks (for npm global installs)
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
// Resolve symlinks to get the real path
// For npm global installs, execPath might be a symlink like:
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
realPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
// If we can't resolve symlinks, fall back to the original path
realPath = execPath
if Config.Verbose {
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
}
}
binDir := path.Dir(realPath)
installDir := path.Dir(binDir)
clineCorePath := path.Join(installDir, "cline-core.js")
if Config.Verbose {
fmt.Printf("Executable path: %s\n", execPath)
if realPath != execPath {
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
}
fmt.Printf("Bin directory: %s\n", binDir)
fmt.Printf("Install directory: %s\n", installDir)
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
}
// Check if cline-core.js exists at the primary location
var finalClineCorePath string
var finalInstallDir string
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
// Development mode: Try ../../dist-standalone/cline-core.js
// This handles the case where we're running from cli/bin/cline
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
if Config.Verbose {
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
}
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
}
finalClineCorePath = devClineCorePath
finalInstallDir = devInstallDir
if Config.Verbose {
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
}
} else {
finalClineCorePath = clineCorePath
finalInstallDir = installDir
if Config.Verbose {
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
}
}
// Create logs directory in ~/.cline/logs
logsDir := path.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
logFilePath := path.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Start the cline-core process with --config flag using system node
args := []string{finalClineCorePath,
"--port", fmt.Sprintf("%d", corePort),
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
"--config", Config.ConfigPath}
if Config.Verbose {
fmt.Printf("Using system node\n")
}
cmd := exec.Command("node", args...)
// Set working directory to installation root
cmd.Dir = finalInstallDir
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
// Set environment variables with NODE_PATH for both real and fake node_modules
// The fake node_modules contains the vscode stub that can't be in the real node_modules
env := os.Environ()
realNodeModules := path.Join(finalInstallDir, "node_modules")
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
env = append(env,
fmt.Sprintf("NODE_PATH=%s", nodePath),
"GRPC_TRACE=all",
"GRPC_VERBOSITY=DEBUG",
"NODE_ENV=development",
)
cmd.Env = env
if Config.Verbose {
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
}
return cmd, nil
}
-113
View File
@@ -1,113 +0,0 @@
package global
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/charmbracelet/lipgloss"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/client"
"github.com/muesli/termenv"
)
type Port uint16
type GlobalConfig struct {
ConfigPath string
Verbose bool
OutputFormat string
CoreAddress string
}
var (
Config *GlobalConfig
Clients *ClineClients
// Version info - set at build time via ldflags
// Version is the Cline Core version (from root package.json)
Version = "dev"
// CliVersion is the CLI package version (from cli/package.json)
CliVersion = "dev"
Commit = "unknown"
Date = "unknown"
BuiltBy = "unknown"
)
func InitializeGlobalConfig(cfg *GlobalConfig) error {
if cfg.ConfigPath == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
cfg.ConfigPath = filepath.Join(homeDir, ".cline")
}
// Ensure .cline directory exists
if err := os.MkdirAll(cfg.ConfigPath, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Configure lipgloss color profile based on output format
if cfg.OutputFormat == "plain" {
lipgloss.SetColorProfile(termenv.Ascii) // NO COLOR mode
}
// Otherwise lipgloss auto-detects terminal capabilities (default behavior)
Config = cfg
Clients = NewClineClients(cfg.ConfigPath)
// Initialize the clients registry
ctx := context.Background()
if err := Clients.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize clients: %w", err)
}
return nil
}
// GetDefaultClient returns a client for the default instance or the address override
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
// User specified a specific address, use that
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
}
// Use the default instance from registry
return Clients.GetRegistry().GetDefaultClient(ctx)
}
// GetClientForAddress returns a client for a specific address
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
return Clients.GetRegistry().GetClient(ctx, address)
}
// EnsureDefaultInstance ensures a default instance exists
func EnsureDefaultInstance(ctx context.Context) error {
if Clients == nil {
return fmt.Errorf("global clients not initialized")
}
registry := Clients.GetRegistry()
// First, check if there are any instances already registered in SQLite
instances := registry.ListInstances()
// Use the registry's EnsureDefaultInstance to auto-set first instance as default if needed
if err := registry.EnsureDefaultInstance(instances); err != nil {
return fmt.Errorf("failed to ensure default from existing instances: %w", err)
}
// Now check if we have a default set
if registry.GetDefaultInstance() == "" {
// No instances exist, start a new one
// Note: StartNewInstance will automatically set it as default since it's the first instance
_, err := Clients.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start new default instance: %w", err)
}
}
return nil
}
-304
View File
@@ -1,304 +0,0 @@
package global
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/cline/cli/pkg/cli/sqlite"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
"github.com/cline/grpc-go/host"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health/grpc_health_v1"
)
// ClientRegistry manages Cline client connections using direct SQLite operations
type ClientRegistry struct {
lockManager *sqlite.LockManager
configPath string
}
// NewClientRegistry creates a new client registry
func NewClientRegistry(configPath string) *ClientRegistry {
lockManager, err := sqlite.NewLockManager(configPath)
if err != nil {
// Log error but continue - we can still function without SQLite
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
}
return &ClientRegistry{
lockManager: lockManager,
configPath: configPath,
}
}
// GetDefaultInstance returns the default instance address from settings file
func (r *ClientRegistry) GetDefaultInstance() string {
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
if err != nil {
return ""
}
return defaultAddr
}
// SetDefaultInstance sets the default instance (writes default.json)
func (r *ClientRegistry) SetDefaultInstance(address string) error {
// Verify the instance exists in SQLite
if r.lockManager != nil {
exists, err := r.lockManager.HasInstanceAtAddress(address)
if err != nil {
return fmt.Errorf("failed to check instance existence: %w", err)
}
if !exists {
return fmt.Errorf("instance %s not found in registry", address)
}
}
return sqlite.SetDefaultInstance(r.configPath, address)
}
// GetInstance returns instance information directly from SQLite
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
if r.lockManager == nil {
return nil, fmt.Errorf("lock manager not available")
}
return r.lockManager.GetInstanceInfo(address)
}
// GetClient returns a connected client for the given address (created on-demand)
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
// Verify instance exists in SQLite
if r.lockManager != nil {
exists, err := r.lockManager.HasInstanceAtAddress(address)
if err != nil {
return nil, fmt.Errorf("failed to check instance existence: %w", err)
}
if !exists {
return nil, fmt.Errorf("instance %s not found", address)
}
}
// Create client on-demand (no caching)
target, err := common.NormalizeAddressForGRPC(address)
if err != nil {
return nil, fmt.Errorf("invalid address %s: %w", address, err)
}
cl, err := client.NewClineClient(target)
if err != nil {
return nil, fmt.Errorf("failed to create client for %s: %w", target, err)
}
if err := cl.Connect(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to %s: %w", target, err)
}
return cl, nil
}
// GetDefaultClient returns a client for the default instance
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
defaultAddr := r.GetDefaultInstance()
if defaultAddr == "" {
return nil, fmt.Errorf("no default instance configured")
}
// Check if the default instance actually exists in the database
if r.lockManager != nil {
exists, err := r.lockManager.HasInstanceAtAddress(defaultAddr)
if err != nil {
// Database is unavailable - Return error instead of attempting cleanup
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
}
if !exists {
// Instance doesn't exist in database but config file references it
// This is a stale config - remove it and try to find another instance
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
if removeErr := os.Remove(settingsPath); removeErr != nil && !os.IsNotExist(removeErr) {
fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr)
} else {
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
}
// Try to find and set a new default instance
instances := r.ListInstances()
if len(instances) > 0 {
if err := r.EnsureDefaultInstance(instances); err != nil {
return nil, fmt.Errorf("failed to set new default instance: %w", err)
}
// Retry with the new default
newDefaultAddr := r.GetDefaultInstance()
if newDefaultAddr != "" {
fmt.Printf("Set new default instance: %s\n", newDefaultAddr)
return r.GetClient(ctx, newDefaultAddr)
}
}
return nil, fmt.Errorf("no default instance configured")
}
}
return r.GetClient(ctx, defaultAddr)
}
// ListInstances returns all registered instances directly from SQLite
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
if r.lockManager == nil {
return []*common.CoreInstanceInfo{}
}
// Use context with timeout for health checks
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
if err != nil {
fmt.Printf("Warning: Failed to list instances: %v\n", err)
return []*common.CoreInstanceInfo{}
}
return instances
}
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
if r.lockManager == nil {
return false
}
exists, err := r.lockManager.HasInstanceAtAddress(address)
if err != nil {
fmt.Printf("Warning: Failed to check instance existence: %v\n", err)
return false
}
return exists
}
// CleanupStaleInstances removes stale instances using direct SQLite operations
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
if r.lockManager == nil {
return nil
}
// Get all instances with health checks
instances, err := r.lockManager.ListInstancesWithHealthCheck(ctx)
if err != nil {
return fmt.Errorf("failed to list instances for cleanup: %w", err)
}
// Clean up all stale instances
for _, instance := range instances {
if instance.Status != grpc_health_v1.HealthCheckResponse_SERVING {
// Try to gracefully shutdown the paired host process before cleanup
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
instance.HostServiceAddress, instance.Address)
r.tryShutdownHostProcess(instance.HostServiceAddress)
// Remove from SQLite database
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
}
fmt.Printf("Removed stale instance: %s\n", instance.Address)
}
}
return nil
}
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
// Best effort, don't throw errors i guess
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
err := common.RetryOperation(3, 2*time.Second, func() error {
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Create gRPC connection to host bridge
conn, err := grpc.DialContext(ctx, hostServiceAddress,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock())
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
defer conn.Close()
// Create env service client and call shutdown
envClient := host.NewEnvServiceClient(conn)
_, err = envClient.Shutdown(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("RPC failed: %w", err)
}
return nil
})
if err != nil {
fmt.Printf("Warning: Failed to request host bridge shutdown on port %s: %v\n", hostServiceAddress, err)
} else {
fmt.Printf("Host bridge shutdown requested successfully on port %s\n", hostServiceAddress)
}
}
// ListInstancesCleaned performs cleanup and returns instances with health checks
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
// 1. Clean up stale entries (best-effort)
_ = r.CleanupStaleInstances(ctx)
// 2. Get all instances with real-time health checks
instances := r.ListInstances()
// 3. Ensure default is set if instances exist
if err := r.EnsureDefaultInstance(instances); err != nil {
fmt.Printf("Warning: Failed to ensure default instance: %v\n", err)
}
return instances, nil
}
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
currentDefault := r.GetDefaultInstance()
// If we have no instances, clear any stale default and remove settings file
if len(instances) == 0 {
if currentDefault != "" {
// Remove the settings file since no instances exist
settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json")
_ = os.Remove(settingsPath)
}
return nil
}
// If we have instances but no default, pick the first one
if currentDefault == "" {
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
}
// Validate current default still exists in the instances
defaultExists := false
for _, instance := range instances {
if instance.Address == currentDefault {
defaultExists = true
break
}
}
if !defaultExists {
// Current default doesn't exist, pick a new one from available instances
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
}
return nil
}
-339
View File
@@ -1,339 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"strings"
"github.com/cline/cli/pkg/cli/clerror"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
)
// AskHandler handles ASK type messages
type AskHandler struct {
*BaseHandler
}
// NewAskHandler creates a new ASK handler
func NewAskHandler() *AskHandler {
return &AskHandler{
BaseHandler: NewBaseHandler("ask", PriorityHigh),
}
}
// CanHandle returns true if this is an ASK message
func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool {
return msg.IsAsk()
}
func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
// Always display approval messages so user can see what they're approving
// The input handler will show the approval prompt form after the content is displayed
switch msg.Ask {
case string(types.AskTypeFollowup):
return h.handleFollowup(msg, dc)
case string(types.AskTypePlanModeRespond):
return h.handlePlanModeRespond(msg, dc)
case string(types.AskTypeCommand):
return h.handleCommand(msg, dc)
case string(types.AskTypeCommandOutput):
return h.handleCommandOutput(msg, dc)
case string(types.AskTypeCompletionResult):
return h.handleCompletionResult(msg, dc)
case string(types.AskTypeTool):
return h.handleTool(msg, dc)
case string(types.AskTypeAPIReqFailed):
return h.handleAPIReqFailed(msg, dc)
case string(types.AskTypeResumeTask):
return h.handleResumeTask(msg, dc)
case string(types.AskTypeResumeCompletedTask):
return h.handleResumeCompletedTask(msg, dc)
case string(types.AskTypeMistakeLimitReached):
return h.handleMistakeLimitReached(msg, dc)
case string(types.AskTypeBrowserActionLaunch):
return h.handleBrowserActionLaunch(msg, dc)
case string(types.AskTypeUseMcpServer):
return h.handleUseMcpServer(msg, dc)
case string(types.AskTypeNewTask):
return h.handleNewTask(msg, dc)
case string(types.AskTypeCondense):
return h.handleCondense(msg, dc)
case string(types.AskTypeReportBug):
return h.handleReportBug(msg, dc)
default:
return h.handleDefault(msg, dc)
}
}
// handleFollowup handles followup questions
func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error {
body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text)
if body == "" {
return nil
}
if dc.IsStreamingMode {
// In streaming mode, header was already shown by partial stream
// Just render the body content
output.Print(body)
} else {
// Non-streaming mode: render header + body together
header := dc.ToolRenderer.GenerateAskFollowupHeader()
rendered := dc.Renderer.RenderMarkdown(header)
output.Print("\n")
output.Print(rendered)
output.Print("\n")
output.Print(body)
}
return nil
}
// handlePlanModeRespond handles plan mode responses
func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.IsStreamingMode {
// In streaming mode, header was already shown by partial stream
// Just render the body content
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
if body != "" {
output.Print(body)
}
} else {
// In non-streaming mode, render header + body together
header := dc.ToolRenderer.GeneratePlanModeRespondHeader()
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
if body == "" {
return nil
}
// Render header
rendered := dc.Renderer.RenderMarkdown(header)
output.Print("\n")
output.Print(rendered)
output.Print("\n")
// Render body
output.Print(body)
}
return nil
}
// showApprovalHint displays a hint in non-interactive mode about how to approve/deny
func (h *AskHandler) showApprovalHint(dc *DisplayContext) {
if !dc.IsInteractive {
output.Printf("\n%s\n", dc.Renderer.Dim("Cline is requesting approval to use this tool"))
output.Printf("%s\n", dc.Renderer.Dim("Use cline task send --approve or --deny to respond"))
}
}
// handleCommand handles command execution requests
func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
// Check if this command was flagged despite auto-approval settings
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
// Use unified ToolRenderer
rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
output.Print(rendered)
h.showApprovalHint(dc)
return nil
}
// handleCommandOutput handles command output requests
func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
commandOutput := msg.Text
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
return nil
}
// handleCompletionResult handles completion result requests
func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleTool handles tool execution requests
func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
// Parse tool message
var tool types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
// Fallback to simple display
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
}
if dc.IsStreamingMode {
// In streaming mode, header was already shown by partial stream
// Just render the content preview
contentPreview := dc.ToolRenderer.GenerateToolContentPreview(&tool)
if contentPreview != "" {
output.Print("\n")
output.Print(contentPreview)
}
} else {
// Non-streaming mode: render full approval (header + preview)
rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
output.Print(rendered)
}
h.showApprovalHint(dc)
return nil
}
// handleAPIReqFailed handles API request failures
func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error {
// Try to parse as ClineError for better error display
clineErr, _ := clerror.ParseClineError(msg.Text)
if clineErr != nil {
if dc.SystemRenderer != nil {
// Render the error with system renderer
switch clineErr.GetErrorType() {
case clerror.ErrorTypeBalance:
dc.SystemRenderer.RenderBalanceError(clineErr)
case clerror.ErrorTypeAuth:
dc.SystemRenderer.RenderAuthError(clineErr)
case clerror.ErrorTypeRateLimit:
dc.SystemRenderer.RenderRateLimitError(clineErr)
default:
dc.SystemRenderer.RenderAPIError(clineErr)
}
return nil
}
// Fallback: render with basic renderer using parsed message
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", clineErr.Message), true)
}
// Last resort: display raw text if parsing completely failed
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true)
}
// handleResumeTask handles resume task requests
func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error {
// Don't render - this is metadata only, user already knows they're resuming
return nil
}
// handleResumeCompletedTask handles resume completed task requests
func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error {
// Don't render - this is metadata only, user already knows they're resuming
return nil
}
// handleMistakeLimitReached handles mistake limit reached
func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.SystemRenderer != nil {
details := make(map[string]string)
if msg.Text != "" {
details["details"] = msg.Text
}
dc.SystemRenderer.RenderError(
"critical",
"Mistake Limit Reached",
"Cline has made too many consecutive mistakes and needs your guidance to proceed.",
details,
)
fmt.Printf("\n**Approval required to continue.**\n")
return nil
}
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
}
// handleBrowserActionLaunch handles browser action launch requests
func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
url := strings.TrimSpace(msg.Text)
err := dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true)
h.showApprovalHint(dc)
return err
}
// handleUseMcpServer handles MCP server usage requests
func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
// Parse MCP server usage request
type McpServerRequest struct {
ServerName string `json:"serverName"`
Type string `json:"type"`
ToolName string `json:"toolName,omitempty"`
Arguments string `json:"arguments,omitempty"`
URI string `json:"uri,omitempty"`
}
var mcpReq McpServerRequest
if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil {
return dc.Renderer.RenderMessage("MCP", msg.Text, true)
}
var operation string
if mcpReq.Type == "access_mcp_resource" {
operation = "access a resource"
} else {
operation = fmt.Sprintf("use a tool (%s)", mcpReq.ToolName)
if mcpReq.Arguments != "" {
operation = fmt.Sprintf("%s with args (%s)", operation, mcpReq.Arguments)
}
}
err := dc.Renderer.RenderMessage("MCP",
fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true)
h.showApprovalHint(dc)
return err
}
// handleNewTask handles new task creation requests
func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true)
}
// handleCondense handles conversation condensing requests
func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true)
}
// handleReportBug handles bug report requests
func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error {
var bugData struct {
Title string `json:"title"`
WhatHappened string `json:"what_happened"`
StepsToReproduce string `json:"steps_to_reproduce"`
APIRequestOutput string `json:"api_request_output"`
AdditionalContext string `json:"additional_context"`
}
if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil {
return dc.Renderer.RenderMessage("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true)
}
err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true)
if err != nil {
return fmt.Errorf("failed to render handleReportBug: %w", err)
}
fmt.Printf("\n**Title**: %s\n", bugData.Title)
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
fmt.Printf("\nApprove to create a GitHub issue.\n")
return nil
}
// handleDefault handles unknown ASK message types
func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("ASK", msg.Text, true)
}
-130
View File
@@ -1,130 +0,0 @@
package handlers
import (
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/types"
)
// MessageHandler defines the interface for handling different message types
type MessageHandler interface {
// CanHandle returns true if this handler can process the given message
CanHandle(msg *types.ClineMessage) bool
// Handle processes the message and renders it using the display context
Handle(msg *types.ClineMessage, dc *DisplayContext) error
// GetPriority returns the priority of this handler (higher = more priority)
GetPriority() int
// GetName returns a human-readable name for this handler
GetName() string
}
// DisplayContext provides context and utilities for message handlers
type DisplayContext struct {
State *types.ConversationState
Renderer *display.Renderer
ToolRenderer *display.ToolRenderer
SystemRenderer *display.SystemMessageRenderer
IsLast bool
IsPartial bool
Verbose bool
MessageIndex int
IsStreamingMode bool
IsInteractive bool
}
// BaseHandler provides common functionality for message handlers
type BaseHandler struct {
name string
priority int
}
// NewBaseHandler creates a new base handler
func NewBaseHandler(name string, priority int) *BaseHandler {
return &BaseHandler{
name: name,
priority: priority,
}
}
// GetName returns the handler name
func (h *BaseHandler) GetName() string {
return h.name
}
// GetPriority returns the handler priority
func (h *BaseHandler) GetPriority() int {
return h.priority
}
// HandlerRegistry manages a collection of message handlers
type HandlerRegistry struct {
handlers []MessageHandler
}
// NewHandlerRegistry creates a new handler registry
func NewHandlerRegistry() *HandlerRegistry {
return &HandlerRegistry{
handlers: make([]MessageHandler, 0),
}
}
// Register adds a handler to the registry
func (r *HandlerRegistry) Register(handler MessageHandler) {
r.handlers = append(r.handlers, handler)
// Sort handlers by priority (highest first)
for i := len(r.handlers) - 1; i > 0; i-- {
if r.handlers[i].GetPriority() > r.handlers[i-1].GetPriority() {
r.handlers[i], r.handlers[i-1] = r.handlers[i-1], r.handlers[i]
} else {
break
}
}
}
// Handle finds the appropriate handler and processes the message
func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
for _, handler := range r.handlers {
if handler.CanHandle(msg) {
return handler.Handle(msg, dc)
}
}
// If no specific handler found, use default text handler
return r.handleDefault(msg, dc)
}
// handleDefault provides default handling for unrecognized messages
func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
prefix := "RESPONSE:"
return dc.Renderer.RenderMessage(prefix, msg.Text, true)
}
// GetHandlers returns all registered handlers
func (r *HandlerRegistry) GetHandlers() []MessageHandler {
return r.handlers
}
// GetHandlerByName finds a handler by name
func (r *HandlerRegistry) GetHandlerByName(name string) MessageHandler {
for _, handler := range r.handlers {
if handler.GetName() == name {
return handler
}
}
return nil
}
// HandlerPriorities defines standard priority levels for handlers
const (
PriorityHigh = 100
PriorityNormal = 50
PriorityLow = 10
)
-521
View File
@@ -1,521 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"strings"
"github.com/cline/cli/pkg/cli/clerror"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/cli/pkg/cli/output"
)
// SayHandler handles SAY type messages
type SayHandler struct {
*BaseHandler
}
// NewSayHandler creates a new SAY handler
func NewSayHandler() *SayHandler {
return &SayHandler{
BaseHandler: NewBaseHandler("say", PriorityNormal),
}
}
// CanHandle returns true if this is a SAY message
func (h *SayHandler) CanHandle(msg *types.ClineMessage) bool {
return msg.IsSay()
}
// Handle processes SAY messages
func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
timestamp := msg.GetTimestamp()
switch msg.Say {
case string(types.SayTypeTask):
return h.handleTask(msg, dc)
case string(types.SayTypeError):
return h.handleError(msg, dc)
case string(types.SayTypeAPIReqStarted):
return h.handleAPIReqStarted(msg, dc)
case string(types.SayTypeAPIReqFinished):
return h.handleAPIReqFinished(msg, dc)
case string(types.SayTypeText):
return h.handleText(msg, dc)
case string(types.SayTypeReasoning):
return h.handleReasoning(msg, dc)
case string(types.SayTypeCompletionResult):
return h.handleCompletionResult(msg, dc)
case string(types.SayTypeUserFeedback):
return h.handleUserFeedback(msg, dc)
case string(types.SayTypeUserFeedbackDiff):
return h.handleUserFeedbackDiff(msg, dc)
case string(types.SayTypeAPIReqRetried):
return h.handleAPIReqRetried(msg, dc)
case string(types.SayTypeErrorRetry):
return h.handleErrorRetry(msg, dc)
case string(types.SayTypeCommand):
return h.handleCommand(msg, dc)
case string(types.SayTypeCommandOutput):
return h.handleCommandOutput(msg, dc)
case string(types.SayTypeTool):
return h.handleTool(msg, dc)
case string(types.SayTypeShellIntegrationWarning):
return h.handleShellIntegrationWarning(msg, dc)
case string(types.SayTypeBrowserActionLaunch):
return h.handleBrowserActionLaunch(msg, dc)
case string(types.SayTypeBrowserAction):
return h.handleBrowserAction(msg, dc)
case string(types.SayTypeBrowserActionResult):
return h.handleBrowserActionResult(msg, dc)
case string(types.SayTypeMcpServerRequestStarted):
return h.handleMcpServerRequestStarted(msg, dc)
case string(types.SayTypeMcpServerResponse):
return h.handleMcpServerResponse(msg, dc)
case string(types.SayTypeMcpNotification):
return h.handleMcpNotification(msg, dc)
case string(types.SayTypeUseMcpServer):
return h.handleUseMcpServer(msg, dc)
case string(types.SayTypeDiffError):
return h.handleDiffError(msg, dc)
case string(types.SayTypeDeletedAPIReqs):
return h.handleDeletedAPIReqs(msg, dc)
case string(types.SayTypeClineignoreError):
return h.handleClineignoreError(msg, dc)
case string(types.SayTypeCheckpointCreated):
return h.handleCheckpointCreated(msg, dc, timestamp)
case string(types.SayTypeLoadMcpDocumentation):
return h.handleLoadMcpDocumentation(msg, dc)
case string(types.SayTypeInfo):
return h.handleInfo(msg, dc)
case string(types.SayTypeTaskProgress):
return h.handleTaskProgress(msg, dc)
default:
return h.handleDefault(msg, dc)
}
}
// handleTask handles task messages
func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleError handles error messages
func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("ERROR", msg.Text, true)
}
// handleAPIReqStarted handles API request started messages
func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error {
// Parse API request info
apiInfo := types.APIRequestInfo{Cost: -1}
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil {
return dc.Renderer.RenderMessage("API INFO", msg.Text, true)
}
// Check for streaming failed message with error details
if apiInfo.StreamingFailedMessage != "" {
clineErr, _ := clerror.ParseClineError(apiInfo.StreamingFailedMessage)
if clineErr != nil {
return h.renderClineError(clineErr, dc)
}
}
// Handle different API request states
if apiInfo.CancelReason != "" {
if apiInfo.CancelReason == "user_cancelled" {
return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true)
} else if apiInfo.CancelReason == "retries_exhausted" {
return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true)
}
return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true)
}
if apiInfo.Cost >= 0 {
return dc.Renderer.RenderAPI("request completed", &apiInfo)
}
// Check for retry status
if apiInfo.RetryStatus != nil {
return dc.Renderer.RenderRetry(
apiInfo.RetryStatus.Attempt,
apiInfo.RetryStatus.MaxAttempts,
apiInfo.RetryStatus.DelaySec)
}
return dc.Renderer.RenderAPI("processing request", &apiInfo)
}
// renderClineError renders a ClineError with appropriate formatting based on type
func (h *SayHandler) renderClineError(err *clerror.ClineError, dc *DisplayContext) error {
if dc.SystemRenderer == nil {
return dc.Renderer.RenderMessage("ERROR", err.Message, true)
}
switch err.GetErrorType() {
case clerror.ErrorTypeBalance:
return dc.SystemRenderer.RenderBalanceError(err)
case clerror.ErrorTypeAuth:
return dc.SystemRenderer.RenderAuthError(err)
case clerror.ErrorTypeRateLimit:
return dc.SystemRenderer.RenderRateLimitError(err)
default:
return dc.SystemRenderer.RenderAPIError(err)
}
}
// handleAPIReqFinished handles API request finished messages
func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error {
// This message type is typically not displayed as it's handled by the started message
return nil
}
// handleText handles regular text messages
func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
// Special case for the user's task input
if dc.MessageIndex == 0 {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
output.Printf("%s", rendered)
output.Printf("\n")
return nil
}
// Regular Cline text response
var rendered string
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(msg.Text)
output.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
rendered = dc.Renderer.RenderMarkdown(markdown)
output.Printf("\n%s\n", rendered)
}
return nil
}
// handleReasoning handles reasoning messages
func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
var rendered string
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(msg.Text)
output.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
rendered = dc.Renderer.RenderMarkdown(markdown)
output.Printf("\n%s\n", rendered)
}
return nil
}
func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error {
text := msg.Text
if strings.HasSuffix(text, "HAS_CHANGES") {
text = strings.TrimSuffix(text, "HAS_CHANGES")
}
var rendered string
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(text)
output.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
rendered = dc.Renderer.RenderMarkdown(markdown)
output.Printf("\n%s\n", rendered)
}
return nil
}
func formatUserMessage(text string) string {
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
}
// handleUserFeedback handles user feedback messages
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text != "" {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
output.Printf("%s", rendered)
return nil
} else {
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
}
}
// handleUserFeedbackDiff handles user feedback diff messages
func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error {
var toolMsg types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil {
return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true)
}
message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s",
toolMsg.Path,
toolMsg.Diff)
return dc.Renderer.RenderMessage("USER DIFF", message, true)
}
// handleAPIReqRetried handles API request retry messages
func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("API INFO", "Retrying request", true)
}
// handleErrorRetry handles error retry status messages
func (h *SayHandler) handleErrorRetry(msg *types.ClineMessage, dc *DisplayContext) error {
// Parse retry info from message text
type ErrorRetryInfo struct {
Attempt int `json:"attempt"`
MaxAttempts int `json:"maxAttempts"`
DelaySeconds int `json:"delaySeconds"`
Failed bool `json:"failed"`
}
var retryInfo ErrorRetryInfo
if err := json.Unmarshal([]byte(msg.Text), &retryInfo); err != nil {
// Fallback to simple message if parsing fails
return dc.Renderer.RenderMessage("API INFO", "Auto-retry in progress", true)
}
if retryInfo.Failed {
// Retry failed after max attempts
message := fmt.Sprintf("Auto-retry failed after %d attempts. Manual intervention required.", retryInfo.MaxAttempts)
if dc.SystemRenderer != nil {
return dc.SystemRenderer.RenderWarning("Auto-Retry Failed", message)
}
return dc.Renderer.RenderMessage("WARNING", message, true)
}
// Retry in progress
message := fmt.Sprintf("Attempt %d/%d - Retrying in %d seconds...",
retryInfo.Attempt, retryInfo.MaxAttempts, retryInfo.DelaySeconds)
return dc.Renderer.RenderMessage("API INFO", message, true)
}
// handleCommand handles command execution announcements
func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
// Use unified ToolRenderer
rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text)
output.Print(rendered)
return nil
}
// handleCommandOutput handles command output messages
func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
// Use unified ToolRenderer
rendered := dc.ToolRenderer.RenderCommandOutput(msg.Text)
output.Print(rendered)
return nil
}
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
var tool types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
return dc.Renderer.RenderMessage("TOOL", msg.Text, true)
}
// Use unified ToolRenderer
rendered := dc.ToolRenderer.RenderToolExecution(&tool)
output.Print(rendered)
return nil
}
// handleShellIntegrationWarning handles shell integration warning messages
func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true)
}
// handleBrowserActionLaunch handles browser action launch messages
func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error {
url := msg.Text
if url == "" {
return nil
}
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true)
}
// handleBrowserAction handles browser action messages
func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
type BrowserActionData struct {
Action string `json:"action"`
Coordinate string `json:"coordinate,omitempty"`
Text string `json:"text,omitempty"`
}
var actionData BrowserActionData
if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil {
return dc.Renderer.RenderMessage("BROWSER", msg.Text, true)
}
// Special handling for type action
if actionData.Action == "type" && actionData.Text != "" {
actionText := fmt.Sprintf("type '%s'", actionData.Text)
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
}
// Special handling for click action
if actionData.Action == "click" && actionData.Coordinate != "" {
actionText := fmt.Sprintf("click (%s)", actionData.Coordinate)
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true)
}
// Generic handling for all other actions
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true)
}
// handleBrowserActionResult handles browser action result messages
func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
type BrowserActionResult struct {
Screenshot string `json:"screenshot,omitempty"`
Logs string `json:"logs,omitempty"`
CurrentUrl string `json:"currentUrl,omitempty"`
CurrentMousePosition string `json:"currentMousePosition,omitempty"`
}
var result BrowserActionResult
if err := json.Unmarshal([]byte(msg.Text), &result); err != nil {
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
}
// If we have logs, include them in the message
if result.Logs != "" {
return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true)
}
// Default case
return dc.Renderer.RenderMessage("BROWSER", "Action completed", true)
}
// handleMcpServerRequestStarted handles MCP server request started messages
func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", "Sending request to server", true)
}
// handleMcpServerResponse handles MCP server response messages
func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true)
}
// handleMcpNotification handles MCP notification messages
func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true)
}
// handleUseMcpServer handles MCP server usage messages
func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("MCP", "Server operation approved", true)
}
// handleDiffError handles diff error messages
func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.SystemRenderer != nil {
return dc.SystemRenderer.RenderWarning(
"Diff Edit Failure",
"The model used search patterns that don't match anything in the file. Retrying...",
)
}
return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true)
}
// handleDeletedAPIReqs handles deleted API requests messages
func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error {
// Don't render - this is internal metadata (aggregated API metrics from deleted checkpoint messages)
return nil
}
// handleClineignoreError handles .clineignore error messages
func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.SystemRenderer != nil {
return dc.SystemRenderer.RenderInfo(
"Access Denied",
fmt.Sprintf("Cline tried to access `%s` which is blocked by the .clineignore file.", msg.Text),
)
}
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true)
}
func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error {
if dc.SystemRenderer != nil {
return dc.SystemRenderer.RenderCheckpoint(timestamp, msg.Timestamp)
}
// Fallback to basic renderer if SystemRenderer not available
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp)
rendered := dc.Renderer.RenderMarkdown(markdown)
output.Print(rendered)
return nil
}
// handleLoadMcpDocumentation handles load MCP documentation messages
func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error {
if dc.SystemRenderer != nil {
return dc.SystemRenderer.RenderInfo("MCP", "Loading MCP documentation")
}
return dc.Renderer.RenderMessage("INFO", "Loading MCP documentation", true)
}
// handleInfo handles info messages
func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error {
return nil
}
// handleTaskProgress handles task progress messages
func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
output.Printf("\n%s\n", rendered)
return nil
}
// handleDefault handles unknown SAY message types
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
}
-506
View File
@@ -1,506 +0,0 @@
package cli
import (
"context"
"fmt"
"os"
"strings"
"syscall"
"text/tabwriter"
"time"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/common"
client2 "github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
"github.com/spf13/cobra"
"google.golang.org/grpc/health/grpc_health_v1"
)
const (
platformCLI = "CLI"
platformJetBrains = "JetBrains"
platformNA = "N/A"
hostPlatformCLI = "Cline CLI" // Value returned by host bridge for CLI instances
)
// detectInstancePlatform connects to an instance's host bridge and determines its platform
func detectInstancePlatform(ctx context.Context, instance *common.CoreInstanceInfo) (string, error) {
hostTarget, err := common.NormalizeAddressForGRPC(instance.HostServiceAddress)
if err != nil {
return platformNA, err
}
hostClient, err := client2.NewClineClient(hostTarget)
if err != nil {
return platformNA, err
}
defer hostClient.Disconnect()
if err := hostClient.Connect(ctx); err != nil {
return platformNA, err
}
hostVersion, err := hostClient.Env.GetHostVersion(ctx, &cline.EmptyRequest{})
if err != nil {
return platformNA, err
}
if hostVersion.Platform == nil {
return platformNA, fmt.Errorf("host returned nil platform")
}
platformStr := *hostVersion.Platform
if platformStr == hostPlatformCLI {
return platformCLI, nil
}
return platformJetBrains, nil
}
func NewInstanceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "instance",
Aliases: []string{"i"},
Short: "Manage Cline instances",
Long: `List and manage multiple Cline instances similar to kubectl contexts.`,
}
cmd.AddCommand(newInstanceListCommand())
cmd.AddCommand(newInstanceDefaultCommand())
cmd.AddCommand(newInstanceNewCommand())
cmd.AddCommand(newInstanceKillCommand())
return cmd
}
func newInstanceKillCommand() *cobra.Command {
var killAllCLI bool
cmd := &cobra.Command{
Use: "kill <address>",
Aliases: []string{"k"},
Short: "Kill a Cline instance by address",
Long: `Kill a running Cline instance and clean up its registry entry.`,
Args: func(cmd *cobra.Command, args []string) error {
if killAllCLI && len(args) > 0 {
return fmt.Errorf("cannot specify both --all-cli flag and address argument")
}
if !killAllCLI && len(args) != 1 {
return fmt.Errorf("requires exactly one address argument when --all-cli is not specified")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if global.Clients == nil {
return fmt.Errorf("clients not initialized")
}
ctx := cmd.Context()
registry := global.Clients.GetRegistry()
if killAllCLI {
return killAllCLIInstances(ctx, registry)
} else {
return global.KillInstanceByAddress(ctx, registry, args[0])
}
},
}
cmd.Flags().BoolVarP(&killAllCLI, "all-cli", "a", false, "kill all running CLI instances (excludes JetBrains)")
return cmd
}
func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) error {
// Get all instances from registry
instances, err := registry.ListInstancesCleaned(ctx)
if err != nil {
return fmt.Errorf("failed to list instances: %w", err)
}
if len(instances) == 0 {
fmt.Println("No Cline instances found to kill.")
return nil
}
// Filter to only CLI instances
var cliInstances []*common.CoreInstanceInfo
var skippedNonCLI int
for _, instance := range instances {
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
platform, err := detectInstancePlatform(ctx, instance)
if err == nil {
if platform == platformCLI {
cliInstances = append(cliInstances, instance)
} else {
skippedNonCLI++
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
}
}
}
}
if len(cliInstances) == 0 {
if skippedNonCLI > 0 {
fmt.Printf("No CLI instances to kill. Skipped %d JetBrains instance(s).\n", skippedNonCLI)
} else {
fmt.Println("No CLI instances found to kill.")
}
return nil
}
fmt.Printf("Killing %d CLI instance(s)...\n", len(cliInstances))
if skippedNonCLI > 0 {
fmt.Printf("Skipping %d JetBrains instance(s).\n", skippedNonCLI)
}
var killResults []killResult
killedAddresses := make(map[string]bool)
// Kill all CLI instances
for _, instance := range cliInstances {
result := killInstanceProcess(ctx, registry, instance.Address)
killResults = append(killResults, result)
if result.err != nil {
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
} else if result.alreadyDead {
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
} else {
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
killedAddresses[instance.Address] = true
}
}
// Wait for killed instances to clean up their registry entries
if len(killedAddresses) > 0 {
fmt.Printf("Waiting for instances to clean up registry entries...\n")
maxWaitTime := 10 // seconds
for i := 0; i < maxWaitTime; i++ {
time.Sleep(1 * time.Second)
remainingInstances, err := registry.ListInstancesCleaned(ctx)
if err != nil {
fmt.Printf("Warning: failed to check registry status: %v\n", err)
continue
}
// Check if any of the killed instances are still in the registry
stillPresent := []string{}
for _, remaining := range remainingInstances {
if killedAddresses[remaining.Address] {
stillPresent = append(stillPresent, remaining.Address)
}
}
if len(stillPresent) == 0 {
fmt.Printf("✓ All killed instances successfully removed from registry.\n")
break
}
if i == maxWaitTime-1 {
fmt.Printf("⚠ %d killed instance(s) still in registry after %d seconds\n", len(stillPresent), maxWaitTime)
for _, addr := range stillPresent {
fmt.Printf(" - %s\n", addr)
}
}
}
}
// Print summary
successful := 0
failed := 0
alreadyDead := 0
for _, result := range killResults {
if result.err != nil {
failed++
} else if result.alreadyDead {
alreadyDead++
} else {
successful++
}
}
fmt.Printf("\nSummary: ")
if successful > 0 {
fmt.Printf("Successfully killed %d instances. ", successful)
}
if alreadyDead > 0 {
fmt.Printf("%d were already dead. ", alreadyDead)
}
if failed > 0 {
fmt.Printf("%d failures.", failed)
return fmt.Errorf("failed to kill %d out of %d instances", failed, len(instances))
}
fmt.Println()
return nil
}
type killResult struct {
address string
pid int
alreadyDead bool
err error
}
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
pid := int(processInfo.ProcessId)
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return killResult{address: address, pid: pid, err: err}
}
return killResult{address: address, pid: pid, err: nil}
}
func newInstanceListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"l"},
Short: "List all registered Cline instances",
Long: `List all registered Cline instances with their status and connection details.`,
RunE: func(cmd *cobra.Command, args []string) error {
if global.Clients == nil {
return fmt.Errorf("clients not initialized")
}
ctx := cmd.Context()
registry := global.Clients.GetRegistry()
// Load, cleanup stale local entries, and update health
instances, err := registry.ListInstancesCleaned(ctx)
if err != nil {
return fmt.Errorf("failed to list instances: %w", err)
}
defaultInstance := registry.GetDefaultInstance()
if len(instances) == 0 {
fmt.Println("No Cline instances found.")
fmt.Println("Run 'cline instance new' to start a new instance, or 'cline task new \"...\"' to auto-start one.")
return nil
}
// Build instance data
type instanceRow struct {
address string
status string
version string
lastSeen string
pid string
platform string
isDefault string
}
var rows []instanceRow
for _, instance := range instances {
isDefault := ""
if instance.Address == defaultInstance {
isDefault = "✓"
}
lastSeen := instance.LastSeen.Format("15:04:05")
if time.Since(instance.LastSeen) > 24*time.Hour {
lastSeen = instance.LastSeen.Format("2006-01-02")
}
// Get PID and platform via RPC if instance is healthy
pid := platformNA
platform := platformNA
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
// Get PID from core
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
pid = fmt.Sprintf("%d", processInfo.ProcessId)
// Update version from RPC if available
if processInfo.Version != nil && *processInfo.Version != "" && *processInfo.Version != "unknown" {
instance.Version = *processInfo.Version
}
}
}
// Get platform from host bridge
if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil {
platform = detectedPlatform
}
}
rows = append(rows, instanceRow{
address: instance.Address,
status: instance.Status.String(),
version: instance.Version,
lastSeen: lastSeen,
pid: pid,
platform: platform,
isDefault: isDefault,
})
}
// Check output format
if global.Config.OutputFormat == "plain" {
// Use tabwriter for plain output
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tPLATFORM\tDEFAULT")
for _, row := range rows {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
row.address,
row.status,
row.version,
row.lastSeen,
row.pid,
row.platform,
row.isDefault,
)
}
w.Flush()
} else {
// Use markdown table for rich output
var markdown strings.Builder
markdown.WriteString("| **ADDRESS (ID)** | **STATUS** | **VERSION** | **LAST SEEN** | **PID** | **PLATFORM** | **DEFAULT** |\n")
markdown.WriteString("|---------|--------|---------|-----------|-----|----------|---------|")
for _, row := range rows {
markdown.WriteString(fmt.Sprintf("\n| %s | %s | %s | %s | %s | %s | %s |",
row.address,
row.status,
row.version,
row.lastSeen,
row.pid,
row.platform,
row.isDefault,
))
}
// Render the markdown table with terminal width for nice table layout
mdRenderer, err := display.NewMarkdownRendererForTerminal()
if err != nil {
// Fallback to plain table if markdown renderer fails
fmt.Println(markdown.String())
} else {
rendered, err := mdRenderer.Render(markdown.String())
if err != nil {
fmt.Println(markdown.String())
} else {
// Post-process to colorize status values
colorRenderer := display.NewRenderer(global.Config.OutputFormat)
rendered = strings.ReplaceAll(rendered, "SERVING", colorRenderer.Green("SERVING"))
rendered = strings.ReplaceAll(rendered, "✓", colorRenderer.Green("✓"))
rendered = strings.ReplaceAll(rendered, "NOT_SERVING", colorRenderer.Red("NOT_SERVING"))
rendered = strings.ReplaceAll(rendered, "UNKNOWN", colorRenderer.Yellow("UNKNOWN"))
fmt.Print(strings.TrimLeft(rendered, "\n"))
}
fmt.Println("\n")
}
}
return nil
},
}
return cmd
}
func newInstanceDefaultCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "default <address>",
Aliases: []string{"d"},
Short: "Set the default Cline instance",
Long: `Set the default Cline instance to use for subsequent commands.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
address := args[0]
if global.Clients == nil {
return fmt.Errorf("clients not initialized")
}
registry := global.Clients.GetRegistry()
// Verify the instance exists
_, err := registry.GetInstance(address)
if err != nil {
return fmt.Errorf("instance %s not found. Run 'cline instance list' to see available instances", address)
}
// Set as default
if err := registry.SetDefaultInstance(address); err != nil {
return fmt.Errorf("failed to set default instance: %w", err)
}
fmt.Printf("Switched to instance: %s\n", address)
return nil
},
}
return cmd
}
func newInstanceNewCommand() *cobra.Command {
var setDefault bool
cmd := &cobra.Command{
Use: "new",
Aliases: []string{"n"},
Short: "Create a new Cline instance",
Long: `Create a new Cline instance with automatically assigned ports.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
if global.Clients == nil {
return fmt.Errorf("clients not initialized")
}
fmt.Println("Starting new Cline instance...")
instance, err := global.Clients.StartNewInstance(ctx)
if err != nil {
return fmt.Errorf("failed to start instance: %w", err)
}
fmt.Printf("Successfully started new instance:\n")
fmt.Printf(" Address: %s\n", instance.Address)
fmt.Printf(" Core Port: %d\n", instance.CorePort())
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
registry := global.Clients.GetRegistry()
// If --default flag provided, set this instance as the default
if setDefault {
if err := registry.SetDefaultInstance(instance.Address); err != nil {
fmt.Printf("Warning: Failed to set as default: %v\n", err)
} else {
fmt.Printf(" Status: Set as default instance\n")
}
} else {
// Otherwise, check if EnsureDefaultInstance already set it as default
if registry.GetDefaultInstance() == instance.Address {
fmt.Printf(" Status: Default instance\n")
}
}
return nil
},
}
cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance")
return cmd
}

Some files were not shown because too many files have changed in this diff Show More