diff --git a/.changeset/fruity-crabs-mate.md b/.changeset/fruity-crabs-mate.md new file mode 100644 index 00000000000..be3f12f5bdd --- /dev/null +++ b/.changeset/fruity-crabs-mate.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Fixed proto naming issue - RPC >>> Rpc diff --git a/.changeset/legal-shirts-drop.md b/.changeset/legal-shirts-drop.md deleted file mode 100644 index 3a06cb60abd..00000000000 --- a/.changeset/legal-shirts-drop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Added checkpoints warning when users start a multiroot task diff --git a/.changeset/short-carrots-tie.md b/.changeset/short-carrots-tie.md new file mode 100644 index 00000000000..68662dde534 --- /dev/null +++ b/.changeset/short-carrots-tie.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Support Feature Flags default values diff --git a/.changeset/slick-seas-love.md b/.changeset/slick-seas-love.md deleted file mode 100644 index 9f85aaadc2f..00000000000 --- a/.changeset/slick-seas-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"claude-dev": patch ---- - -Added markdown support to focus chain text, allowing the model to display more interesting focus chains diff --git a/.changeset/smooth-items-hammer.md b/.changeset/smooth-items-hammer.md new file mode 100644 index 00000000000..a8082e6c516 --- /dev/null +++ b/.changeset/smooth-items-hammer.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Adding oca as a provider to cline cli diff --git a/.changeset/wet-islands-film.md b/.changeset/wet-islands-film.md new file mode 100644 index 00000000000..b688038cb1c --- /dev/null +++ b/.changeset/wet-islands-film.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Changes to allow users to manually enter model names (eg. presets) when using OpenRouter diff --git a/.clinerules/hooks/PostToolUse.example b/.clinerules/hooks/PostToolUse.example new file mode 100755 index 00000000000..876fbdecdd3 --- /dev/null +++ b/.clinerules/hooks/PostToolUse.example @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# PostToolUse Hook Example +# +# This hook runs AFTER a tool is executed. It can: +# 1. Observe tool results and outcomes +# 2. Add context for FUTURE tool uses via contextModification +# 3. Log or track tool usage patterns +# +# IMPORTANT: Context injection affects FUTURE AI decisions, not the current tool execution. +# The tool has already completed when this hook runs. + +# Read the hook input (JSON via stdin) +input=$(cat) + +# Extract tool information +tool_name=$(echo "$input" | jq -r '.postToolUse.toolName // "unknown"') +parameters=$(echo "$input" | jq -r '.postToolUse.parameters // {}') +result=$(echo "$input" | jq -r '.postToolUse.result // ""') +success=$(echo "$input" | jq -r '.postToolUse.success // false') +execution_time=$(echo "$input" | jq -r '.postToolUse.executionTimeMs // 0') + +# Example 1: Learning from file operations +# Track successful file creations to build context about project structure +# if [[ "$tool_name" == "write_to_file" && "$success" == "true" ]]; then +# path=$(echo "$parameters" | jq -r '.path // ""') +# cat <> ~/.cline/hook-logs/tool-usage.jsonl + +# Allow execution +echo '{"shouldContinue": true}' +``` + +## Global vs Workspace Hooks + +Cline supports two levels of hooks: + +### Global Hooks +- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows) +- **Scope**: Apply to ALL workspaces and projects +- **Use Case**: Organization-wide policies, personal preferences, universal validations +- **Priority**: Execute FIRST, before workspace hooks + +### Workspace Hooks +- **Location**: `.clinerules/hooks/` in each workspace root +- **Scope**: Apply only to the specific workspace +- **Use Case**: Project-specific rules, team conventions, repository requirements +- **Priority**: Execute AFTER global hooks + +### Hook Execution + +When multiple hooks exist (global and/or workspace): +- All hooks for a given step (PreToolUse or PostToolUse) are executed +- **Execution order is not guaranteed** - hooks may run concurrently +- If ALL hooks allow execution (`shouldContinue: true`), the tool proceeds +- If ANY hook blocks (`shouldContinue: false`), execution is blocked + +**Result Combination:** +- `shouldContinue`: Must be `true` from ALL hooks for execution to proceed +- `contextModification`: All context strings are concatenated +- `errorMessage`: All error messages are concatenated + +### Setting Up Global Hooks + +1. The global hooks directory is automatically created at: + - macOS/Linux: `~/Documents/Cline/Rules/Hooks/` + - Windows: `%USERPROFILE%\Documents\Cline\Rules\Hooks\` + +2. Add your hook script: + ```bash + # Unix/Linux/macOS + nano ~/Documents/Cline/Rules/Hooks/PreToolUse + chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse + + # Windows + notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse + ``` + +3. Enable hooks in Cline settings + +### Example: Global + Workspace Hooks + +**Global Hook** (applies to all projects): +```bash +#!/usr/bin/env bash +# ~/Documents/Cline/Rules/Hooks/PreToolUse +# Universal rule: Never delete package.json +input=$(cat) +tool_name=$(echo "$input" | jq -r '.preToolUse.toolName') +path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""') + +if [[ "$tool_name" == "write_to_file" && "$path" == *"package.json"* ]]; then + echo '{"shouldContinue": false, "errorMessage": "Global policy: Cannot modify package.json"}' + exit 0 +fi + +echo '{"shouldContinue": true}' +``` + +**Workspace Hook** (applies to specific project): +```bash +#!/usr/bin/env bash +# .clinerules/hooks/PreToolUse +# Project rule: Only TypeScript files +input=$(cat) +tool_name=$(echo "$input" | jq -r '.preToolUse.toolName') +path=$(echo "$input" | jq -r '.preToolUse.parameters.path // ""') + +if [[ "$tool_name" == "write_to_file" && "$path" == *.js ]]; then + echo '{"shouldContinue": false, "errorMessage": "Project rule: Use .ts files only"}' + exit 0 +fi + +echo '{"shouldContinue": true}' +``` + +**All hooks must allow execution for the tool to proceed.** Hooks may execute concurrently. + +## Multi-Root Workspaces + +If you have multiple workspace roots, you can place hooks in each root's `.clinerules/hooks/` directory. All hooks (global and workspace) may execute concurrently. Their results will be combined: + +- **shouldContinue**: If ANY hook returns false, execution is blocked +- **contextModification**: All context modifications are concatenated +- **errorMessage**: All error messages are concatenated + +**Note:** No execution order is guaranteed between hooks from different directories. + +## Troubleshooting + +### Hook Not Running +- Ensure the "Enable Hooks" setting is checked +- Verify the hook file is executable (`chmod +x hookname`) +- Check the hook file has no syntax errors +- Look for errors in VSCode's Output panel (Cline channel) + +### Hook Timing Out +- Reduce complexity of the hook script +- Avoid expensive operations (network calls, heavy computations) +- Consider moving complex logic to a background process + +### Context Not Affecting Behavior +- Remember: context affects FUTURE decisions, not the current tool +- Use PreToolUse for validation (blocking) if you need immediate effect +- Ensure context modifications are clear and actionable +- Check that context isn't being truncated (50KB limit) + +## Security Considerations + +- Hooks run with the same permissions as VSCode +- Be cautious with hooks from untrusted sources +- Review hook scripts before enabling them +- Consider using `.gitignore` to avoid committing sensitive hook logic +- Hooks can access all workspace files and environment variables + +## Best Practices + +1. **Keep hooks fast** - Aim for <100ms execution time +2. **Make context actionable** - Be specific about what the AI should do +3. **Use structured prefixes** - Help the AI categorize context +4. **Handle errors gracefully** - Always return valid JSON +5. **Log for debugging** - Keep logs of hook executions for troubleshooting +6. **Test incrementally** - Start with simple hooks and add complexity +7. **Document your hooks** - Add comments explaining the purpose and logic diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..c3b812a2593 --- /dev/null +++ b/.env.example @@ -0,0 +1,116 @@ +# Cline Development Environment Variables +# Copy this file to .env and fill in your actual values +# Values should be obtained from 1Password shared vault for development + +# ============================================================================ +# DEVELOPMENT FLAGS +# Recomend not changing these unless you know what you're doing they are set by the launch.json normally +# ============================================================================ +# IS_DEV=true +# CLINE_ENVIRONMENT=local + +# ============================================================================ +# POSTHOG TELEMETRY (Existing) +# ============================================================================ +# Get these values from 1Password shared vault +TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key +ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key + +# ============================================================================ +# TELEMETRY PROVIDER CONTROL +# ============================================================================ +# Control which telemetry providers are active +POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true) + # Set to false to disable Telemetry completely + +# ============================================================================ +# OPENTELEMETRY (Optional - for advanced telemetry) +# ============================================================================ +# OpenTelemetry provides flexible telemetry collection with multiple export options +# Can run alongside PostHog or independently +# Primary focus: Logs (events), with optional metrics support + +# Enable OpenTelemetry (set to 1 to enable) +# OTEL_TELEMETRY_ENABLED=1 + +# Exporters: "console" for local debugging, "otlp" for remote collector +# Logs are the primary signal (recommended) +# OTEL_LOGS_EXPORTER=console +# OTEL_METRICS_EXPORTER=otlp + +# OTLP Protocol: "grpc", "http/json", or "http/protobuf" +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +# OTLP Endpoint (without /v1/logs or /v1/metrics path - auto-appended) +# For gRPC: use "localhost:4317" (no http:// prefix) +# For HTTP: use "http://localhost:4318" +# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 + +# OTLP Headers (for authentication, e.g., bearer tokens) +# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token-here + +# Use insecure gRPC connections (for local testing only, NOT for production) +# OTEL_EXPORTER_OTLP_INSECURE=true + +# Metric export interval in milliseconds (default: 60000) +# OTEL_METRIC_EXPORT_INTERVAL=10000 + +# Batch configuration for logs (optional) +# OTEL_LOG_BATCH_SIZE=512 # Max logs per batch (default: 512) +# OTEL_LOG_BATCH_TIMEOUT=5000 # Max wait time in ms (default: 5000) +# OTEL_LOG_MAX_QUEUE_SIZE=2048 # Max queue size (default: 2048) + +# Enable detailed export diagnostics (for debugging) +# TEL_DEBUG_DIAGNOSTICS=true + +# Advanced: Separate endpoints for metrics and logs (optional) +# OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/protobuf +# OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://metrics.example.com:4318 +# OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=grpc +# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=logs.example.com:4317 + +# Example configurations: +# +# Console debugging (logs only): +# OTEL_TELEMETRY_ENABLED=1 +# OTEL_LOGS_EXPORTER=console +# TEL_DEBUG_DIAGNOSTICS=true +# +# OTLP with gRPC (insecure, for local testing): +# OTEL_TELEMETRY_ENABLED=1 +# OTEL_LOGS_EXPORTER=otlp +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc +# OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +# OTEL_EXPORTER_OTLP_INSECURE=true +# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token +# +# OTLP with HTTP/JSON (production): +# OTEL_TELEMETRY_ENABLED=1 +# OTEL_LOGS_EXPORTER=otlp +# OTEL_EXPORTER_OTLP_PROTOCOL=http/json +# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com +# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token + +# ============================================================================ +# OPTIONAL DEVELOPMENT SETTINGS +# ============================================================================ +# Uncomment and modify as needed for development + +# Multi-root workspace debugging +# MULTI_ROOT_TRACE=true + +# gRPC recorder for testing +# GRPC_RECORDER_ENABLED=true +# GRPC_RECORDER_FILE_NAME=test-recording + +# Test mode +# E2E_TEST=true +# IS_TEST=true + +# ============================================================================ +# USAGE INSTRUCTIONS +# ============================================================================ +# 1. Copy this file: cp .env.example .env +# 2. Get PostHog keys from 1Password shared vault +# 3. Update the values in .env +# 4. The .env file is gitignored for security diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 23037bb881e..cc865c7dad4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ /docs/ /.github/ @saoudrizwan @garoth @sjf /README.md @saoudrizwan @nickbaumann98 +/src/core/storage/ @celestial-vault diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3f3c556f19b..3574691092c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -14,6 +14,7 @@ body: options: - VSCode Extension - JetBrains Plugin + - CLI default: 0 validations: required: true diff --git a/.github/workflows/label-jetbrains-issues.yml b/.github/workflows/label-jetbrains-issues.yml new file mode 100644 index 00000000000..ae1dfb604a8 --- /dev/null +++ b/.github/workflows/label-jetbrains-issues.yml @@ -0,0 +1,53 @@ +name: Auto-label Issues + +on: + issues: + types: [opened, edited] + +jobs: + label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const body = context.payload.issue.body || ''; + const labels = context.payload.issue.labels.map(l => l.name); + + // Check if JetBrains Plugin is selected + if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) { + if (!labels.includes('JetBrains')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['JetBrains'] + }); + } + } + + // Check if VSCode Extension is selected + if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) { + if (!labels.includes('VS Code')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['VS Code'] + }); + } + } + + // Check if CLI is selected + if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) { + if (!labels.includes('CLI')) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['CLI'] + }); + } + } diff --git a/.github/workflows/publish-nightly.yml b/.github/workflows/publish-nightly.yml index 148604ac895..9e8c82635f1 100644 --- a/.github/workflows/publish-nightly.yml +++ b/.github/workflows/publish-nightly.yml @@ -72,4 +72,12 @@ jobs: TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} CLINE_ENVIRONMENT: production - run: npm run publish:marketplace:nightly \ No newline at end of file + # OpenTelemetry production defaults (can be overridden at runtime) + OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} + OTEL_LOGS_EXPORTER: ${{ secrets.OTEL_LOGS_EXPORTER }} + OTEL_METRICS_EXPORTER: ${{ secrets.OTEL_METRICS_EXPORTER }} + OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }} + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + OTEL_METRIC_EXPORT_INTERVAL: ${{ secrets.OTEL_METRIC_EXPORT_INTERVAL }} + run: npm run publish:marketplace:nightly diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 71c7743998a..4e51b89af5a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -97,6 +97,14 @@ jobs: CLINE_ENVIRONMENT: production TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }} ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }} + # OpenTelemetry production defaults (can be overridden at runtime) + OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }} + OTEL_LOGS_EXPORTER: ${{ secrets.OTEL_LOGS_EXPORTER }} + OTEL_METRICS_EXPORTER: ${{ secrets.OTEL_METRICS_EXPORTER }} + OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }} + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + OTEL_METRIC_EXPORT_INTERVAL: ${{ secrets.OTEL_METRIC_EXPORT_INTERVAL }} run: | # Required to generate the .vsix vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a34cea6cc59..9053678642f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -187,8 +187,20 @@ jobs: if: steps.webview-cache.outputs.cache-hit != 'true' run: cd webview-ui && npm ci - - name: Compile standalone - run: npm run compile-standalone + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: cli/go.sum + + - name: Build CLI binaries + run: npm run compile-cli-all-platforms + + - name: Download ripgrep binaries + run: npm run download-ripgrep + + - name: Compile NPM package + run: npm run compile-standalone-npm - name: Install testing platform dependencies if: steps.testing-platform-cache.outputs.cache-hit != 'true' @@ -244,11 +256,14 @@ jobs: - name: Download test platform integration core coverage artifact uses: actions/download-artifact@v4 + continue-on-error: true + id: download-integration-coverage with: name: test-platform-integration-core-coverage path: integration-core-coverage-reports - name: Upload core integration tests coverage to Qlty + if: steps.download-integration-coverage.outcome == 'success' uses: qltysh/qlty-action/coverage@v2 with: token: ${{ secrets.QLTY_COVERAGE_TOKEN }} diff --git a/.gitignore b/.gitignore index 3f1a75dda9c..0164cec8d91 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ coverage-unit !.github/scripts/coverage/ *evals.env +.env ## Generated files ## src/generated/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 6bb6b15ccb4..df4e6540e9a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -19,6 +19,7 @@ "${workspaceFolder}/dist/**/*.js" ], "preLaunchTask": "${defaultBuildTask}", + "envFile": "${workspaceFolder}/.env", "env": { "IS_DEV": "true", "DEV_WORKSPACE_FOLDER": "${workspaceFolder}", @@ -39,6 +40,7 @@ "${workspaceFolder}/dist/**/*.js" ], "preLaunchTask": "${defaultBuildTask}", + "envFile": "${workspaceFolder}/.env", "env": { "IS_DEV": "true", "DEV_WORKSPACE_FOLDER": "${workspaceFolder}", @@ -59,6 +61,7 @@ "${workspaceFolder}/dist/**/*.js" ], "preLaunchTask": "${defaultBuildTask}", + "envFile": "${workspaceFolder}/.env", "env": { "IS_DEV": "true", "DEV_WORKSPACE_FOLDER": "${workspaceFolder}", @@ -84,6 +87,7 @@ "preLaunchTask": "clean-tmp-user", "internalConsoleOptions": "openOnSessionStart", "postDebugTask": "stop", + "envFile": "${workspaceFolder}/.env", "env": { "IS_DEV": "true", "TEMP_PROFILE": "true", @@ -114,6 +118,7 @@ "tsx" ], "program": "scripts/test-standalone-core-api-server.ts", + "envFile": "${workspaceFolder}/.env", "env": { "PROTOBUS_PORT": "26040", "HOSTBRIDGE_PORT": "26041", @@ -151,6 +156,7 @@ "--exit", "${file}" ], + "envFile": "${workspaceFolder}/.env", "env": { "TS_NODE_PROJECT": "./tsconfig.unit-test.json", "NODE_ENV": "test", diff --git a/.vscodeignore b/.vscodeignore index 98f99486e90..a4405dcb29e 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,6 +18,7 @@ tsconfig*.json eslint-rules/** .github/** .husky/** +.env # Custom **/demo.gif @@ -36,6 +37,9 @@ buf.yaml .changeset/ .clinerules/ +# Include specific file needed for Background Exec mode +!standalone/runtime-files/vscode/enhanced-terminal.js + # Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore) webview-ui/src/** webview-ui/public/** @@ -69,4 +73,4 @@ test-results/ **/*.stories.tsx *storybook.log storybook-static -**/StorybookDecorator.tsx \ No newline at end of file +**/StorybookDecorator.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d823cbb6e25..2cc836791fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,36 @@ # Changelog +## [3.34.0] + +- Cline Teams is now free through 2025 for unlimited users. Includes Jetbrains, RBAC, centralized billing and more. +- Use the “exacto” versions of GLM-4.6, Kimi-K2, and Qwen3-Coder in the Cline provider for the best balance of cost, speed, accuracy and tool-calling. + +## [3.33.1] + +- Fix CLI installation copy text + +## [3.33.0] + +- Added Cline CLI (Preview) +- Added Subagent support (Experimental) +- Added Multi-Root Workspaces support (Enable in feature settings) +- Add auto-retry with exponential backof for failed API requests + +## [3.32.8] + +- Add Claude Haiku 4.5 support + +## [3.32.7] + +- Add JP and Global inference profile options to AWS Bedrock +- Adding Improvements to VSCode multi root workspaces +- Added markdown support to focus chain text, allowing the model to display more interesting focus chains + ## [3.32.6] - Add experimental support for VSCode multi root workspaces - Add Claude Sonnet 4.5 to Claude Code provider -- Add Glm 4.6 to Z AI provider +- Add Glm 4.6 to Z AI provider ## [3.32.5] diff --git a/cli/.gitignore b/cli/.gitignore index 92501931374..cad49fc9feb 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,2 +1,2 @@ cline-core-debug.log -bin/* \ No newline at end of file +bin/* diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000000..d37b7053dcf --- /dev/null +++ b/cli/README.md @@ -0,0 +1,72 @@ +# Cline CLI + +``` +/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\ +\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_ + \:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\ + \:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_ + \:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\ + \_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/ +``` + +Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more. + +## Installation + +Install Cline globally using npm: + +```bash +npm install -g cline +``` + +## Usage + +```bash +cline +``` + +This will start the Cline CLI interface where you can interact with the autonomous coding agent. + +## Features + +- **Autonomous Coding**: AI-powered code generation, editing, and refactoring +- **File Operations**: Create, read, update, and delete files and directories +- **Command Execution**: Run shell commands and scripts +- **Browser Automation**: Interact with web pages and applications +- **Multi-Model Support**: Works with Anthropic Claude, OpenAI GPT, and other AI models +- **MCP Integration**: Extensible through Model Context Protocol servers +- **Project Understanding**: Analyzes codebases to provide context-aware assistance + +## Requirements + +- Node.js 18.0.0 or higher +- Supported platforms: macOS, Linux. Windows soon +- Supported architectures: x64, arm64 + +## Configuration + +Cline can be configured through: + +- Environment variables +- Configuration files +- Command-line arguments + +See the [main documentation](https://cline.bot) for detailed configuration options. + +## Links + +- **Website**: [https://cline.bot](https://cline.bot) +- **Documentation**: [https://docs.cline.bot](https://docs.cline.bot) +- **GitHub**: [https://github.com/cline/cline](https://github.com/cline/cline) +- **VSCode Extension**: Available in the VSCode Marketplace +- **JetBrains Extension**: Available in the JetBrains Marketplace + +## License + +Apache-2.0 - see [LICENSE](https://github.com/cline/cline/blob/main/LICENSE) for details. + +## Support + +- Report issues: [GitHub Issues](https://github.com/cline/cline/issues) +- Community: [GitHub Discussions](https://github.com/cline/cline/discussions) +- Documentation: [docs.cline.bot](https://docs.cline.bot) diff --git a/cli/cline-text-logo.txt b/cli/cline-text-logo.txt deleted file mode 100644 index 5221d005974..00000000000 --- a/cli/cline-text-logo.txt +++ /dev/null @@ -1,6 +0,0 @@ -/_____/\ /_/\ /_______/\/__/\ /__/\ /_____/\ -\:::__\/ \:\ \ \__.::._\/\::\_\\ \ \\::::_\/_ - \:\ \ __\:\ \ \::\ \ \:. `-\ \ \\:\/___/\ - \:\ \/_/\\:\ \____ _\::\ \__\:. _ \ \\::___\/_ - \:\_\ \ \\:\/___/\/__\::\__/\\. \`-\ \ \\:\____/\ - \_____\/ \_____\/\________\/ \__\/ \__\/ \_____\/ diff --git a/cli/cmd/cline/main.go b/cli/cmd/cline/main.go index 2e700f0bc24..f99c3bcb92b 100644 --- a/cli/cmd/cline/main.go +++ b/cli/cmd/cline/main.go @@ -2,56 +2,347 @@ package main import ( "context" + "encoding/json" "fmt" + "io" "os" + "strings" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" "github.com/cline/cli/pkg/cli" + "github.com/cline/cli/pkg/cli/auth" + "github.com/cline/cli/pkg/cli/display" "github.com/cline/cli/pkg/cli/global" "github.com/cline/cli/pkg/common" + "github.com/cline/grpc-go/cline" "github.com/spf13/cobra" ) var ( coreAddress string - cfgFile string verbose bool outputFormat string + + // Task creation flags (for root command) + images []string + files []string + mode string + settings []string + yolo bool + oneshot bool ) func main() { rootCmd := &cobra.Command{ - Use: "cline", + Use: "cline [prompt]", Short: "Cline CLI - AI-powered coding assistant", Long: `A command-line interface for interacting with Cline AI coding assistant. -This CLI provides access to Cline's task management, configuration, and -monitoring capabilities from the terminal.`, +Start a new task by providing a prompt: + cline "Create a new Python script that prints hello world" + +Or pipe a prompt via stdin: + echo "Create a todo app" | cline + cat prompt.txt | cline --yolo + +Or run with no arguments to enter interactive mode: + cline + +This CLI also provides task management, configuration, and monitoring capabilities. + +For detailed documentation including all commands, options, and examples, +see the manual page: man cline`, + Args: cobra.ArbitraryArgs, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if outputFormat != "rich" && outputFormat != "json" && outputFormat != "plain" { return fmt.Errorf("invalid output format '%s': must be one of 'rich', 'json', or 'plain'", outputFormat) } return global.InitializeGlobalConfig(&global.GlobalConfig{ - ConfigPath: cfgFile, Verbose: verbose, OutputFormat: outputFormat, CoreAddress: coreAddress, }) }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + var instanceAddress string + + // If --address flag not provided, start instance BEFORE getting prompt + if !cmd.Flags().Changed("address") { + if global.Config.Verbose { + fmt.Println("Starting new Cline instance...") + } + instance, err := global.Clients.StartNewInstance(ctx) + if err != nil { + return fmt.Errorf("failed to start new instance: %w", err) + } + instanceAddress = instance.Address + if global.Config.Verbose { + fmt.Printf("Started instance at %s\n\n", instanceAddress) + } + + // Set up cleanup on exit + defer func() { + if global.Config.Verbose { + fmt.Println("\nCleaning up instance...") + } + registry := global.Clients.GetRegistry() + if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil { + if global.Config.Verbose { + fmt.Printf("Warning: Failed to clean up instance: %v\n", err) + } + } + }() + + // Check if user has credentials configured + if !isUserReadyToUse(ctx, instanceAddress) { + // Create renderer for welcome messages + renderer := display.NewRenderer(global.Config.OutputFormat) + fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up")) + + if err := auth.HandleAuthMenuNoArgs(ctx); err != nil { + // Check if user cancelled - exit cleanly + if err == huh.ErrUserAborted { + return nil + } + return fmt.Errorf("auth setup failed: %w", err) + } + + // Re-check after auth wizard + if !isUserReadyToUse(ctx, instanceAddress) { + return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup") + } + + fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI")) + } + } else { + // User specified --address flag, use that + instanceAddress = coreAddress + } + + // Get content from both args and stdin + prompt, err := getContentFromStdinAndArgs(args) + if err != nil { + return fmt.Errorf("failed to read prompt: %w", err) + } + + // If no prompt from args or stdin, show interactive input + if prompt == "" { + // Pass the mode flag to banner so it shows correct mode + prompt, err = promptForInitialTask(ctx, instanceAddress, mode) + if err != nil { + // Check if user cancelled - exit cleanly without error + if err == huh.ErrUserAborted { + return nil + } + return err + } + if prompt == "" { + return fmt.Errorf("prompt required") + } + } + + // If oneshot mode, force plan mode and yolo + if oneshot { + mode = "plan" + yolo = true + } + + return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{ + Images: images, + Files: files, + Mode: mode, + Settings: settings, + Yolo: yolo, + Address: instanceAddress, + Verbose: verbose, + }) + }, } rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address") - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cline/config.yaml)") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "rich", "output format (rich|json|plain)") + rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)") + + // Task creation flags (only apply when using root command with prompt) + rootCmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files") + rootCmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files") + rootCmd.Flags().StringVarP(&mode, "mode", "m", "plan", "mode (act|plan) - defaults to plan") + rootCmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format)") + rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)") + rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)") + rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode") rootCmd.AddCommand(cli.NewTaskCommand()) rootCmd.AddCommand(cli.NewInstanceCommand()) + rootCmd.AddCommand(cli.NewConfigCommand()) rootCmd.AddCommand(cli.NewVersionCommand()) rootCmd.AddCommand(cli.NewAuthCommand()) + rootCmd.AddCommand(cli.NewLogsCommand()) + rootCmd.AddCommand(cli.NewDoctorCommand()) if err := rootCmd.ExecuteContext(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } + +func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) { + // Show session banner before the initial input + showSessionBanner(ctx, instanceAddress, modeFlag) + + var prompt string + + // Create custom theme with mode-colored cursor and title + theme := huh.ThemeCharm() + + // Set cursor and title color based on mode + modeColor := lipgloss.Color("3") // Yellow for plan + if modeFlag == "act" { + modeColor = lipgloss.Color("39") // Blue for act + } + + theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor) + theme.Focused.Title = theme.Focused.Title.Foreground(modeColor) + + form := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Start a new Cline task"). + Description("What would you like Cline to help you with?"). + Placeholder("e.g., Create a REST API with authentication..."). + Lines(5). + Value(&prompt), + ), + ).WithWidth(48).WithTheme(theme) + + err := form.Run() + if err != nil { + // Check if user cancelled with Control-C + if err == huh.ErrUserAborted { + // Return a special error that indicates clean cancellation + // This allows deferred cleanup to run + return "", huh.ErrUserAborted + } + return "", err + } + + return strings.TrimSpace(prompt), nil +} + +// showSessionBanner displays session info before initial prompt +func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) { + bannerInfo := display.BannerInfo{ + Version: global.CliVersion, + Mode: modeFlag, // Use the mode from command flag, not state + } + + // If mode is empty, default to "plan" + if bannerInfo.Mode == "" { + bannerInfo.Mode = "plan" + } + + // Get current working directory (this is what Cline will use) + if cwd, err := os.Getwd(); err == nil { + bannerInfo.Workdir = cwd + } + + // Get provider/model using auth functions (same logic as auth menu) + manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress) + if err == nil { + if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil { + // Show provider/model for the mode we'll be using + var providerDisplay *auth.ProviderDisplay + if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil { + providerDisplay = providerList.PlanProvider + } else if bannerInfo.Mode == "act" && providerList.ActProvider != nil { + providerDisplay = providerList.ActProvider + } + + if providerDisplay != nil { + bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider) + bannerInfo.ModelID = providerDisplay.ModelID + } + } + } + + // Render and display banner + banner := display.RenderSessionBanner(bannerInfo) + fmt.Println(banner) + fmt.Println() // Extra spacing before form +} + +// isUserReadyToUse checks if the user has completed initial setup +// Returns true if welcomeViewCompleted flag is set OR user is authenticated +// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid) +func isUserReadyToUse(ctx context.Context, instanceAddress string) bool { + manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress) + if err != nil { + return false + } + + // Get state + state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return false + } + + // Parse state JSON + stateMap := make(map[string]interface{}) + if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil { + return false + } + + // Check 1: welcomeViewCompleted flag + if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted { + return true + } + + // Check 2: Is user authenticated? (matches extension's || user?.uid check) + if userInfo, ok := stateMap["userInfo"].(map[string]interface{}); ok { + if uid, ok := userInfo["uid"].(string); ok && uid != "" { + return true + } + } + + return false +} + +// getContentFromStdinAndArgs reads content from both command line args and stdin, and combines them +func getContentFromStdinAndArgs(args []string) (string, error) { + var content strings.Builder + + // Add command line args first (if any) + if len(args) > 0 { + content.WriteString(strings.Join(args, " ")) + } + + // Check if stdin has data + stat, err := os.Stdin.Stat() + if err != nil { + return "", fmt.Errorf("failed to stat stdin: %w", err) + } + + // Check if data is being piped to stdin + if (stat.Mode() & os.ModeCharDevice) == 0 { + // Only try to read if there's actually data available + if stat.Size() > 0 { + stdinBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("failed to read from stdin: %w", err) + } + + stdinContent := strings.TrimSpace(string(stdinBytes)) + if stdinContent != "" { + if content.Len() > 0 { + content.WriteString(" ") + } + content.WriteString(stdinContent) + } + } + } + + return content.String(), nil +} diff --git a/cli/e2e/sqlite_helper.go b/cli/e2e/sqlite_helper.go index cf3c713c727..f2aeee2908c 100644 --- a/cli/e2e/sqlite_helper.go +++ b/cli/e2e/sqlite_helper.go @@ -10,7 +10,7 @@ import ( "time" "github.com/cline/cli/pkg/common" - _ "github.com/mattn/go-sqlite3" + _ "github.com/glebarez/go-sqlite" "google.golang.org/grpc/health/grpc_health_v1" ) @@ -25,7 +25,7 @@ func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanc return []common.CoreInstanceInfo{} } - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { t.Logf("Warning: Failed to open SQLite database: %v", err) return []common.CoreInstanceInfo{} @@ -97,7 +97,7 @@ func readDefaultInstanceFromSettings(t *testing.T, clineDir string) string { func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePort, hostPort int) error { t.Helper() - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { return err } @@ -142,7 +142,7 @@ func insertRemoteInstanceIntoSQLite(t *testing.T, dbPath, address string, corePo func verifyInstanceExistsInSQLite(t *testing.T, dbPath, address string) bool { t.Helper() - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { t.Logf("Failed to open database: %v", err) return false diff --git a/cli/go.mod b/cli/go.mod index 7430f44bf19..facffc3a819 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,20 +3,62 @@ module github.com/cline/cli go 1.23.0 require ( + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cline/grpc-go v0.0.0 - github.com/mattn/go-sqlite3 v1.14.24 + github.com/glebarez/go-sqlite v1.22.0 + github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.32.0 google.golang.org/grpc v1.75.0 + google.golang.org/protobuf v1.36.6 ) replace github.com/cline/grpc-go => ../src/generated/grpc-go require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect - google.golang.org/protobuf v1.36.6 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index d723b6b3c4d..1231d17952b 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,4 +1,64 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532 h1:+xmbw70JXxmsOqvm1PEIAqFnqI/Hy2RYqrK7CtPmsNY= +github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -7,17 +67,55 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= @@ -30,10 +128,18 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -46,3 +152,11 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9x google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/cli/man/cline.1 b/cli/man/cline.1 new file mode 100644 index 00000000000..a7ddb4c658c --- /dev/null +++ b/cli/man/cline.1 @@ -0,0 +1,331 @@ +.\" Automatically generated by Pandoc 3.8.2 +.\" +.TH "CLINE" "1" "January 2025" "Cline CLI 1.0" "User Commands" +.SH NAME +cline \- orchestrate and interact with Cline AI coding agents +.SH SYNOPSIS +\f[B]cline\f[R] [\f[I]prompt\f[R]] [\f[I]options\f[R]] +.PP +\f[B]cline\f[R] \f[I]command\f[R] [\f[I]subcommand\f[R]] +[\f[I]options\f[R]] [\f[I]arguments\f[R]] +.SH DESCRIPTION +Try: cat README.md | cline \(lqSummarize this for me:\(rq +.PP +\f[B]cline\f[R] is a command\-line interface for orchestrating multiple +Cline AI coding agents. +Cline is an autonomous AI agent who can read, write, and execute code +across your projects. +He operates through a client\-server architecture where \f[B]Cline +Core\f[R] runs as a standalone service, and the CLI acts as a scriptable +interface for managing tasks, instances, and agent interactions. +.PP +The CLI is designed for both interactive use and automation, making it +ideal for CI/CD pipelines, parallel task execution, and terminal\-based +workflows. +Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline +Core instance, enabling seamless task handoff between environments. +.SH MODES OF OPERATION +.TP +\f[B]Instant Task Mode\f[R] +The simplest invocation: \f[B]cline \(lqprompt here\(rq\f[R] immediately +spawns an instance, creates a task, and enters chat mode. +This is equivalent to running \f[B]cline instance new && cline task new +&& cline task chat\f[R] in sequence. +.TP +\f[B]Subcommand Mode\f[R] +Advanced usage with explicit control: \f[B]cline [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. diff --git a/cli/man/cline.1.md b/cli/man/cline.1.md new file mode 100644 index 00000000000..e75227600e0 --- /dev/null +++ b/cli/man/cline.1.md @@ -0,0 +1,332 @@ +--- +title: CLINE +section: 1 +header: User Commands +footer: Cline CLI 1.0 +date: January 2025 +--- + +# NAME + +cline - orchestrate and interact with Cline AI coding agents + +# SYNOPSIS + +**cline** [*prompt*] [*options*] + +**cline** *command* [*subcommand*] [*options*] [*arguments*] + +# DESCRIPTION + +Try: cat README.md | cline "Summarize this for me:" + +**cline** is a command-line interface for orchestrating multiple Cline AI coding agents. Cline is an autonomous AI agent who can read, write, and execute code across your projects. He operates through a client-server architecture where **Cline Core** runs as a standalone service, and the CLI acts as a scriptable interface for managing tasks, instances, and agent interactions. + +The CLI is designed for both interactive use and automation, making it ideal for CI/CD pipelines, parallel task execution, and terminal-based workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to the same Cline Core instance, enabling seamless task handoff between environments. + +# MODES OF OPERATION + +**Instant Task Mode** + +: The simplest invocation: **cline "prompt here"** immediately spawns an instance, creates a task, and enters chat mode. This is equivalent to running **cline instance new && cline task new && cline task chat** in sequence. + +**Subcommand Mode** + +: Advanced usage with explicit control: **cline \ [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: + +For real-time help, join the Discord community at: + +# SEE ALSO + +Full documentation: + +# 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. diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000000..8685305e6f3 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,68 @@ +{ + "name": "cline", + "version": "1.0.0-nightly.18", + "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", + "main": "cline-core.js", + "bin": { + "cline": "./bin/cline", + "cline-host": "./bin/cline-host" + }, + "man": "./man/cline.1", + "scripts": { + "postinstall": "node postinstall.js" + }, + "bundleDependencies": [ + "@grpc/grpc-js", + "@grpc/reflection", + "better-sqlite3", + "grpc-health-check", + "open", + "vscode-uri" + ], + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "cline", + "claude", + "dev", + "mcp", + "openrouter", + "coding", + "agent", + "autonomous", + "chatgpt", + "sonnet", + "ai", + "llama", + "cli" + ], + "author": { + "name": "Cline Bot Inc." + }, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/cline/cline" + }, + "homepage": "https://cline.bot", + "bugs": { + "url": "https://github.com/cline/cline/issues" + }, + "dependencies": { + "@grpc/grpc-js": "^1.13.3", + "@grpc/reflection": "^1.0.4", + "better-sqlite3": "^12.2.0", + "grpc-health-check": "^2.0.2", + "open": "^10.1.2", + "vscode-uri": "^3.1.0" + }, + "os": [ + "darwin", + "linux" + ], + "cpu": [ + "x64", + "arm64" + ] +} diff --git a/cli/pkg/cli/auth.go b/cli/pkg/cli/auth.go index ace9cf3e507..d69e2839886 100644 --- a/cli/pkg/cli/auth.go +++ b/cli/pkg/cli/auth.go @@ -1,125 +1,43 @@ package cli import ( - "bufio" - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/cline/cli/pkg/cli/global" - "github.com/cline/grpc-go/cline" + "github.com/cline/cli/pkg/cli/auth" "github.com/spf13/cobra" ) -var isSessionAuthenticated bool - func NewAuthCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "auth", - Short: "Sign in to Cline", - Long: `Complete the authentication flow in browser to sign in to Cline.`, + Short: "Authenticate a provider and configure what model is used", + Long: `Authenticate a provider and configure what model is used + +Interactive Mode: + Run without flags to open an interactive menu where you can: + - Sign in to your Cline account + - Configure other LLM providers (Anthropic, OpenAI, etc.) + - Select and switch between AI models + - Manage provider settings + +Quick Setup Mode: + Use flags to quickly configure a BYO provider non-interactively: + + Examples: + cline auth --provider openai-native --apikey sk-xxx --modelid gpt-5 + cline auth -p anthropic -k sk-ant-xxx -m claude-sonnet-4-5-20250929 + cline auth -p openai-compatible -k xxx -m gpt-4 -b https://api.example.com/v1 + + Supported providers: openai-native, openai, anthropic, gemini, openrouter, xai, cerebras, ollama + Note: Bedrock provider requires interactive setup due to complex auth fields`, RunE: func(cmd *cobra.Command, args []string) error { - return handleAuthCommand(cmd.Context()) + return auth.RunAuthFlow(cmd.Context(), args) }, } -} - -func handleAuthCommand(ctx context.Context) error { - fmt.Print("Authenticating with Cline...\n") - if IsAuthenticated(ctx) { - return signOutDialog(ctx) - } - - if err := signIn(ctx); err != nil { - return err - } - - fmt.Println("You are signed in!") - return nil -} - -func signOut(ctx context.Context) error { - client, err := global.GetDefaultClient(ctx) - if err != nil { - return err - } - if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil { - return err - } - - isSessionAuthenticated = false - fmt.Println("You have been signed out of Cline.") - return nil -} - -func signOutDialog(ctx context.Context) error { - fmt.Print("You are already signed in to Cline.\nWould you like to sign out? (y/N): ") - - scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() { - return nil - } - - response := strings.ToLower(strings.TrimSpace(scanner.Text())) - if response == "y" || response == "yes" { - if err := signOut(ctx); err != nil { - fmt.Printf("Failed to sign out: %v\n", err) - return err - } - } - return nil -} + // 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)") -func signIn(ctx context.Context) error { - if IsAuthenticated(ctx) { - return nil - } - - verboseLog("Ensuring default instance exists...") - if err := ensureDefaultInstance(ctx); err != nil { - verboseLog("Failed to ensure default instance: %v", err) - return err - } - - verboseLog("Default instance ensured successfully.") - time.Sleep(2 * time.Second) // Allow services to start - - client, err := global.GetDefaultClient(ctx) - if err != nil { - verboseLog("Failed to obtain client: %v", err) - return err - } - - _, err = client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{}) - if err != nil { - verboseLog("Failed to login: %v", err) - return err - } - - isSessionAuthenticated = true - verboseLog("Login successful") - return nil -} - -func IsAuthenticated(ctx context.Context) bool { - if isSessionAuthenticated { - return true - } - - client, err := global.GetDefaultClient(ctx) - if err != nil { - return false - } - - _, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{}) - return err == nil -} - -func verboseLog(format string, args ...interface{}) { - if global.Config != nil && global.Config.Verbose { - fmt.Printf("[VERBOSE] "+format+"\n", args...) - } + return cmd } diff --git a/cli/pkg/cli/auth/auth_cline_provider.go b/cli/pkg/cli/auth/auth_cline_provider.go new file mode 100644 index 00000000000..ab0d05b174e --- /dev/null +++ b/cli/pkg/cli/auth/auth_cline_provider.go @@ -0,0 +1,285 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +var isSessionAuthenticated bool + +// Cline provider specific code + +func HandleClineAuth(ctx context.Context) error { + verboseLog("Authenticating with Cline...") + + // Check if already authenticated + if IsAuthenticated(ctx) { + return signOutDialog(ctx) + } + + // Perform sign in + if err := signIn(ctx); err != nil { + return err + } + + fmt.Println() + + verboseLog("✓ You are signed in!") + + + // Configure default Cline model after successful authentication + if err := configureDefaultClineModel(ctx); err != nil { + fmt.Printf("Warning: Could not configure default Cline model: %v\n", err) + fmt.Println("You can configure a model later with 'cline auth' and selecting 'Change Cline model'") + } + + // Return to main auth menu after successful authentication + return HandleAuthMenuNoArgs(ctx) +} + +func signOut(ctx context.Context) error { + client, err := global.GetDefaultClient(ctx) + if err != nil { + return err + } + + if _, err = client.Account.AccountLogoutClicked(ctx, &cline.EmptyRequest{}); err != nil { + return err + } + + isSessionAuthenticated = false + fmt.Println("You have been signed out of Cline.") + return nil +} + +func signOutDialog(ctx context.Context) error { + var confirm bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("You are already signed in to Cline."). + Description("Would you like to sign out?"). + Value(&confirm), + ), + ) + + if err := form.Run(); err != nil { + return nil + } + + if confirm { + if err := signOut(ctx); err != nil { + fmt.Printf("Failed to sign out: %v\n", err) + return err + } + } + return HandleAuthMenuNoArgs(ctx) +} + +func signIn(ctx context.Context) error { + if IsAuthenticated(ctx) { + return nil + } + + // Subscribe to auth updates before initiating login + verboseLog("Subscribing to auth status updates...") + listener, err := NewAuthStatusListener(ctx) + if err != nil { + verboseLog("Failed to subscribe to auth updates: %v", err) + return fmt.Errorf("failed to subscribe to auth updates: %w", err) + } + defer listener.Stop() + + if err := listener.Start(); err != nil { + verboseLog("Failed to start auth listener: %v", err) + return fmt.Errorf("failed to start auth listener: %w", err) + } + + // Initiate login (opens browser with callback URL from cline-core's AuthHandler) + verboseLog("Initiating login...") + client, err := global.GetDefaultClient(ctx) + if err != nil { + verboseLog("Failed to obtain client: %v", err) + return fmt.Errorf("failed to obtain client: %w", err) + } + + response, err := client.Account.AccountLoginClicked(ctx, &cline.EmptyRequest{}) + if err != nil { + verboseLog("Failed to initiate login: %v", err) + return fmt.Errorf("failed to initiate login: %w", err) + } + + fmt.Println("\n Opening browser for authentication...") + if response != nil && response.Value != "" { + fmt.Printf(" If the browser doesn't open automatically, visit this URL:\n %s\n\n", response.Value) + } + fmt.Println(" Waiting for you to complete authentication in your browser...") + fmt.Println(" (This may take a few moments. Timeout: 5 minutes)") + + // Wait for auth status update confirming success + verboseLog("Waiting for authentication to complete...") + if err := listener.WaitForAuthentication(5 * time.Minute); err != nil { + verboseLog("Authentication failed or timed out: %v", err) + fmt.Println("\n Authentication failed or timed out.") + fmt.Println(" Please try again with 'cline auth'") + return err + } + + // Only NOW set the session flag after confirmed authentication + isSessionAuthenticated = true + verboseLog("Login successful") + return nil +} + +func IsAuthenticated(ctx context.Context) bool { + if isSessionAuthenticated { + verboseLog("Session is already authenticated") + return true + } + + verboseLog("Verifying authentication with server...") + client, err := global.GetDefaultClient(ctx) + if err != nil { + verboseLog("Failed to get client for auth check: %v", err) + return false + } + + _, err = client.Account.GetUserCredits(ctx, &cline.EmptyRequest{}) + if err == nil { + // Update session variable for future fast-path checks + verboseLog("Server verification successful, updating session flag") + isSessionAuthenticated = true + return true + } + + verboseLog("Server verification failed: %v", err) + return false +} + +// HandleChangeClineModel allows Cline-authenticated users to change their Cline model selection. Hidden when not authenticated. +func HandleChangeClineModel(ctx context.Context) error { + // Ensure user is authenticated + if !IsAuthenticated(ctx) { + return fmt.Errorf("you must be authenticated with Cline to change models. Run 'cline auth' to sign in") + } + + // Get task manager + manager, err := createTaskManager(ctx) + if err != nil { + return fmt.Errorf("failed to create task manager: %w", err) + } + + // Launch Cline model selection + return SelectClineModel(ctx, manager) +} + +// configureDefaultClineModel configures the default Cline model after authentication +func configureDefaultClineModel(ctx context.Context) error { + verboseLog("Configuring default Cline model...") + + // Create task manager + manager, err := task.NewManagerForDefault(ctx) + if err != nil { + return fmt.Errorf("failed to create task manager: %w", err) + } + + // Set default Cline model + return SetDefaultClineModel(ctx, manager) +} + +// HandleSelectOrganization allows Cline-authenticated users to select which organization to use +func HandleSelectOrganization(ctx context.Context) error { + // Ensure user is authenticated + if !IsAuthenticated(ctx) { + return fmt.Errorf("you must be authenticated with Cline to select an organization. Run 'cline auth' to sign in") + } + + // Get client + client, err := global.GetDefaultClient(ctx) + if err != nil { + return fmt.Errorf("failed to get client: %w", err) + } + + // Fetch user organizations + orgsResponse, err := client.Account.GetUserOrganizations(ctx, &cline.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to fetch organizations: %w", err) + } + + organizations := orgsResponse.GetOrganizations() + if len(organizations) == 0 { + fmt.Println("You don't have any organizations yet.") + fmt.Println("Visit https://app.cline.bot/dashboard to create an organization.") + return HandleAuthMenuNoArgs(ctx) + } + + // Build options list: Personal + Organizations + var options []huh.Option[string] + options = append(options, huh.NewOption("Personal", "personal")) + + for _, org := range organizations { + displayName := org.Name + // Show active indicator + if org.Active { + displayName = fmt.Sprintf("%s (active)", displayName) + } + options = append(options, huh.NewOption(displayName, org.OrganizationId)) + } + + options = append(options, huh.NewOption("(Cancel)", "cancel")) + + // Show selection menu + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select which account to use"). + Options(options...). + Value(&selected), + ), + ) + + if err := form.Run(); err != nil { + return fmt.Errorf("failed to select organization: %w", err) + } + + if selected == "cancel" { + return HandleAuthMenuNoArgs(ctx) + } + + // Set the organization + var orgId *string + if selected != "personal" { + orgId = &selected + } + + req := &cline.UserOrganizationUpdateRequest{ + OrganizationId: orgId, + } + + if _, err := client.Account.SetUserOrganization(ctx, req); err != nil { + return fmt.Errorf("failed to set organization: %w", err) + } + + if selected == "personal" { + fmt.Println("✓ Switched to personal account") + } else { + // Find the org name to display + var orgName string + for _, org := range organizations { + if org.OrganizationId == selected { + orgName = org.Name + break + } + } + fmt.Printf("✓ Switched to organization: %s\n", orgName) + } + + return HandleAuthMenuNoArgs(ctx) +} \ No newline at end of file diff --git a/cli/pkg/cli/auth/auth_menu.go b/cli/pkg/cli/auth/auth_menu.go new file mode 100644 index 00000000000..887aa53d7f4 --- /dev/null +++ b/cli/pkg/cli/auth/auth_menu.go @@ -0,0 +1,323 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/display" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +// contextKey is a distinct type for context keys to avoid collisions +type contextKey string + +const authInstanceAddressKey contextKey = "authInstanceAddress" + +// AuthAction represents the type of authentication action +type AuthAction string + +const ( + AuthActionClineLogin AuthAction = "cline_login" + AuthActionBYOSetup AuthAction = "provider_setup" + AuthActionChangeClineModel AuthAction = "change_cline_model" + AuthActionSelectOrganization AuthAction = "select_organization" + AuthActionSelectProvider AuthAction = "select_provider" + AuthActionExit AuthAction = "exit_wizard" +) + +// Cline Auth Menu +// Example Layout +// +// ┃ Cline Account: +// ┃ Active Provider: +// ┃ Active Model: +// ┃ +// ┃ 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 --apikey --modelid --baseurl ") + 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...) + } +} diff --git a/cli/pkg/cli/auth/auth_subscription.go b/cli/pkg/cli/auth/auth_subscription.go new file mode 100644 index 00000000000..b1a14a579ed --- /dev/null +++ b/cli/pkg/cli/auth/auth_subscription.go @@ -0,0 +1,130 @@ +package auth + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/grpc-go/cline" +) + +// AuthStatusListener manages subscription to auth status updates +type AuthStatusListener struct { + stream cline.AccountService_SubscribeToAuthStatusUpdateClient + updatesCh chan *cline.AuthState + errCh chan error + ctx context.Context + cancel context.CancelFunc +} + +// NewAuthStatusListener creates a new auth status listener +func NewAuthStatusListener(parentCtx context.Context) (*AuthStatusListener, error) { + client, err := global.GetDefaultClient(parentCtx) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + // Create cancellable context + ctx, cancel := context.WithCancel(parentCtx) + + // Subscribe to auth status updates + stream, err := client.Account.SubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{}) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to subscribe to auth updates: %w", err) + } + + return &AuthStatusListener{ + stream: stream, + updatesCh: make(chan *cline.AuthState, 10), + errCh: make(chan error, 1), + ctx: ctx, + cancel: cancel, + }, nil +} + +// Start begins listening to the auth status update stream +func (l *AuthStatusListener) Start() error { + verboseLog("Starting auth status listener...") + + go l.readStream() + + return nil +} + +// readStream reads from the gRPC stream and forwards messages to channels +func (l *AuthStatusListener) readStream() { + defer close(l.updatesCh) + defer close(l.errCh) + + for { + select { + case <-l.ctx.Done(): + verboseLog("Auth listener context cancelled") + return + default: + state, err := l.stream.Recv() + if err != nil { + if err == io.EOF { + verboseLog("Auth status stream closed") + return + } + verboseLog("Error reading from auth status stream: %v", err) + select { + case l.errCh <- err: + case <-l.ctx.Done(): + } + return + } + + verboseLog("Received auth state update: user=%v", state.User != nil) + + select { + case l.updatesCh <- state: + case <-l.ctx.Done(): + return + } + } + } +} + +// WaitForAuthentication blocks until authentication succeeds or timeout occurs +func (l *AuthStatusListener) WaitForAuthentication(timeout time.Duration) error { + verboseLog("Waiting for authentication (timeout: %v)...", timeout) + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case <-timer.C: + return fmt.Errorf("authentication timeout after %v - please try again", timeout) + + case <-l.ctx.Done(): + return fmt.Errorf("authentication cancelled") + + case err := <-l.errCh: + return fmt.Errorf("authentication stream error: %w", err) + + case state := <-l.updatesCh: + if isAuthenticated(state) { + verboseLog("Authentication successful!") + return nil + } + verboseLog("Received auth update but not authenticated yet...") + } + } +} + +// Stop closes the stream and cleans up resources +func (l *AuthStatusListener) Stop() { + verboseLog("Stopping auth status listener...") + l.cancel() +} + +// isAuthenticated checks if AuthState indicates successful authentication +func isAuthenticated(state *cline.AuthState) bool { + return state != nil && state.User != nil +} diff --git a/cli/pkg/cli/auth/byo_quick_setup.go b/cli/pkg/cli/auth/byo_quick_setup.go new file mode 100644 index 00000000000..e38613248d4 --- /dev/null +++ b/cli/pkg/cli/auth/byo_quick_setup.go @@ -0,0 +1,240 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +// Package-level variables for command-line flags +var ( + QuickProvider string // Provider ID (e.g., "openai", "anthropic") + QuickAPIKey string // API key for the provider + QuickModelID string // Model ID to configure + QuickBaseURL string // Base URL (optional, for openai compatible only) +) + +// QuickSetupFromFlags performs quick setup using command-line flags +// Returns error if validation fails or configuration cannot be applied +func QuickSetupFromFlags(ctx context.Context, provider, apiKey, modelID, baseURL string) error { + // Validate all input parameters + providerEnum, err := validateQuickSetupInputs(provider, apiKey, modelID, baseURL) + if err != nil { + return err + } + + // Create task manager for state operations + manager, err := task.NewManagerForDefault(ctx) + if err != nil { + return fmt.Errorf("failed to create task manager: %w", err) + } + + // Validate and fetch model information if needed + finalModelID, modelInfo, err := validateAndFetchModel(ctx, manager, providerEnum, modelID, apiKey) + if err != nil { + return fmt.Errorf("model validation failed: %w", err) + } + + // For Ollama, baseURL is stored in the API key field + finalAPIKey := apiKey + finalBaseURL := baseURL + if providerEnum == cline.ApiProvider_OLLAMA { + if baseURL != "" { + finalAPIKey = baseURL + finalBaseURL = "" + } else if apiKey != "" { + // User provided API key for Ollama - treat it as baseURL + finalAPIKey = apiKey + finalBaseURL = "" + } else { + // Use default Ollama baseURL + finalAPIKey = "http://localhost:11434" + finalBaseURL = "" + } + } + + // Configure the provider using existing AddProviderPartial function + if err := AddProviderPartial(ctx, manager, providerEnum, finalModelID, finalAPIKey, finalBaseURL, modelInfo); err != nil { + return fmt.Errorf("failed to configure provider: %w", err) + } + + // Set the provider as active for both Plan and Act modes + if err := UpdateProviderPartial(ctx, manager, providerEnum, ProviderUpdatesPartial{}, true); err != nil { + return fmt.Errorf("failed to set provider as active: %w", err) + } + + // Mark welcome view as completed + if err := markWelcomeViewCompleted(ctx, manager); err != nil { + // Non-fatal error, just log it + if global.Config.Verbose { + fmt.Printf("[DEBUG] Warning: failed to mark welcome view as completed: %v\n", err) + } + } + + // Success message + fmt.Printf("\n✓ Successfully configured %s provider\n", GetProviderDisplayName(providerEnum)) + fmt.Printf(" Model: %s\n", finalModelID) + if providerEnum == cline.ApiProvider_OLLAMA { + fmt.Printf(" Base URL: %s\n", finalAPIKey) + } else { + fmt.Println(" API Key: Configured") + } + if finalBaseURL != "" { + fmt.Printf(" Custom Base URL: %s\n", finalBaseURL) + } + fmt.Println("\nYou can now use Cline with this provider.") + fmt.Println("Run 'cline start' to begin a new task.") + + return nil +} + +// validateQuickSetupInputs validates all input parameters for quick setup +// Returns the validated provider enum or an error if validation fails +func validateQuickSetupInputs(provider, apiKey, modelID, baseURL string) (cline.ApiProvider, error) { + // Validate required parameters + if provider == "" { + return cline.ApiProvider_ANTHROPIC, fmt.Errorf("provider is required. Use --provider or -p flag") + } + + if strings.TrimSpace(apiKey) == "" && provider != "ollama" { + return cline.ApiProvider_ANTHROPIC, fmt.Errorf("API key is required for %s provider. Use --apikey or -k flag", provider) + } + + if strings.TrimSpace(modelID) == "" { + return cline.ApiProvider_ANTHROPIC, fmt.Errorf("model ID is required. Use --modelid or -m flag") + } + + // Validate and map provider string to enum + providerEnum, err := validateQuickSetupProvider(provider) + if err != nil { + return cline.ApiProvider_ANTHROPIC, err + } + + // Validate that baseURL is only provided for OpenAI-compatible providers + if err := validateBaseURL(baseURL, providerEnum); err != nil { + return cline.ApiProvider_ANTHROPIC, err + } + + return providerEnum, nil +} + +// validateBaseURL checks if the user's input includes a baseURL for a provider other than OpenAI (compatible) +// Returns error if baseURL is provided for unsupported providers +func validateBaseURL(baseURL string, providerEnum cline.ApiProvider) error { + if providerEnum != cline.ApiProvider_OPENAI { + if baseURL != "" { + return fmt.Errorf("base URL is only supported for OpenAI and OpenAI-compatible providers") + } + } + return nil +} + + +// validateQuickSetupProvider validates the provider ID and returns the enum value +// Returns error if provider is invalid or not supported for quick setup +func validateQuickSetupProvider(providerID string) (cline.ApiProvider, error) { + // Normalize provider ID (trim whitespace, lowercase) + normalizedID := strings.TrimSpace(strings.ToLower(providerID)) + + // Explicitly block Bedrock + if normalizedID == "bedrock" { + return cline.ApiProvider_BEDROCK, fmt.Errorf("bedrock provider is not supported for quick setup due to complex authentication requirements. Please use interactive setup: cline auth") + } + + // Map provider string to enum using existing function + provider, ok := mapProviderStringToEnum(normalizedID) + if !ok { + // Provider not found - provide helpful error message + supportedProviders := []string{ + "openai-native", "openai", "anthropic", "gemini", + "openrouter", "xai", "cerebras", "ollama", + } + return cline.ApiProvider_ANTHROPIC, fmt.Errorf( + "invalid provider '%s'. Supported providers: %s", + providerID, + strings.Join(supportedProviders, ", "), + ) + } + + // Validate against supported quick setup providers + supportedProviders := map[cline.ApiProvider]bool{ + cline.ApiProvider_OPENAI_NATIVE: true, + cline.ApiProvider_OPENAI: true, + cline.ApiProvider_ANTHROPIC: true, + cline.ApiProvider_GEMINI: true, + cline.ApiProvider_OPENROUTER: true, + cline.ApiProvider_XAI: true, + cline.ApiProvider_CEREBRAS: true, + cline.ApiProvider_OLLAMA: true, + } + + if !supportedProviders[provider] { + return provider, fmt.Errorf( + "provider '%s' is not supported for quick setup. Please use interactive setup: cline auth", + providerID, + ) + } + + return provider, nil +} + +// validateAndFetchModel validates the model ID or fetches from provider if needed +// Returns the final model ID and optional model info +// For providers with static models, validates against the list +// For providers with dynamic models, fetches the list if possible +func validateAndFetchModel(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID, apiKey string) (string, interface{}, error) { + // Normalize model ID + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return "", nil, fmt.Errorf("model ID cannot be empty") + } + + // For most providers, we trust the user's input since we can't easily validate without making API calls + // The actual validation will happen when the model is used + switch provider { + case cline.ApiProvider_OPENROUTER: + // OpenRouter supports model info fetching, but it requires an API call + // For quick setup, we'll trust the user's input and return nil for model info + // The actual model info will be fetched when needed + if global.Config.Verbose { + fmt.Printf("[DEBUG] OpenRouter model ID: %s (will be validated on first use)\n", modelID) + } + return modelID, nil, nil + + case cline.ApiProvider_OLLAMA: + // Ollama models can be validated by fetching the list, but this requires the server to be running + // For quick setup, we'll trust the user's input + if global.Config.Verbose { + fmt.Printf("[DEBUG] Ollama model ID: %s (will be validated when server is accessible)\n", modelID) + } + return modelID, nil, nil + + default: + // For other providers (Anthropic, OpenAI, Gemini, XAI, Cerebras), trust user input + // Model validation will occur when the model is actually used + if global.Config.Verbose { + fmt.Printf("[DEBUG] %s model ID: %s (will be validated on first use)\n", GetProviderDisplayName(provider), modelID) + } + return modelID, nil, nil + } +} + +// markWelcomeViewCompleted marks the welcome view as completed in the state +// This prevents the welcome view from showing up after quick setup +func markWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error { + // Use the State service to update the welcome view flag + _, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true}) + if err != nil { + return fmt.Errorf("failed to mark welcome view as completed: %w", err) + } + + if global.Config.Verbose { + fmt.Println("[DEBUG] Marked welcome view as completed") + } + + return nil +} diff --git a/cli/pkg/cli/auth/models_cline.go b/cli/pkg/cli/auth/models_cline.go new file mode 100644 index 00000000000..93bf1f742d5 --- /dev/null +++ b/cli/pkg/cli/auth/models_cline.go @@ -0,0 +1,141 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +// DefaultClineModelID is the default model ID for Cline provider. +// Cline uses OpenRouter-compatible model IDs. +const DefaultClineModelID = "anthropic/claude-sonnet-4.5" + +// FetchClineModels fetches available Cline models from Cline Core. +// Note: Cline provider uses OpenRouter-compatible API and model format. +// The models are fetched using the same method as OpenRouter. +func FetchClineModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) { + if global.Config.Verbose { + fmt.Println("Fetching Cline models (using OpenRouter-compatible API)") + } + + // Cline uses OpenRouter model fetching + models, err := FetchOpenRouterModels(ctx, manager) + if err != nil { + return nil, fmt.Errorf("failed to fetch Cline models: %w", err) + } + + return models, nil +} + +// GetClineModelInfo retrieves information for a specific Cline model. +func GetClineModelInfo(modelID string, models map[string]*cline.OpenRouterModelInfo) (*cline.OpenRouterModelInfo, error) { + modelInfo, exists := models[modelID] + if !exists { + return nil, fmt.Errorf("model %s not found", modelID) + } + return modelInfo, nil +} + +// SetDefaultClineModel configures the default Cline model after authentication. +// This is called automatically after successful Cline sign-in. +func SetDefaultClineModel(ctx context.Context, manager *task.Manager) error { + + // Fetch available models + models, err := FetchClineModels(ctx, manager) + if err != nil { + // If we can't fetch models, we'll use the default without model info + fmt.Printf("Warning: Could not fetch Cline models: %v\n", err) + fmt.Printf("Using default model: %s\n", DefaultClineModelID) + return applyDefaultClineModel(ctx, manager, nil) + } + + // Check if default model is available + modelInfo, err := GetClineModelInfo(DefaultClineModelID, models) + if err != nil { + fmt.Printf("Warning: Default model not found: %v\n", err) + // Try to use any available model + for modelID := range models { + fmt.Printf("Using available model: %s\n", modelID) + return applyClineModelConfiguration(ctx, manager, modelID, models[modelID]) + } + return fmt.Errorf("no usable Cline models found") + } + + if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil { + return err + } + + if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil { + verboseLog("Warning: Failed to mark welcome view as completed: %v", err) + } + + return nil +} + +// SelectClineModel presents a menu to select a Cline model and applies the configuration. +func SelectClineModel(ctx context.Context, manager *task.Manager) error { + + // Fetch models (uses OpenRouter-compatible format) + models, err := FetchClineModels(ctx, manager) + if err != nil { + return fmt.Errorf("failed to fetch Cline models: %w", err) + } + + // Convert to interface map for generic utilities + modelMap := ConvertOpenRouterModelsToInterface(models) + + // Get model IDs as a sorted list + modelIDs := ConvertModelsMapToSlice(modelMap) + + // Display selection menu + selectedModelID, err := DisplayModelSelectionMenu(modelIDs, "Cline") + if err != nil { + return fmt.Errorf("model selection failed: %w", err) + } + + // Get the selected model info + modelInfo := models[selectedModelID] + + // Apply the configuration + if err := applyClineModelConfiguration(ctx, manager, selectedModelID, modelInfo); err != nil { + return err + } + + fmt.Println() + + // Return to main auth menu after model selection + return HandleAuthMenuNoArgs(ctx) +} + +// applyClineModelConfiguration applies a Cline model configuration to both Act and Plan modes using UpdateProviderPartial. +// Cline uses OpenRouter-compatible model format. +func applyClineModelConfiguration(ctx context.Context, manager *task.Manager, modelID string, modelInfo *cline.OpenRouterModelInfo) error { + provider := cline.ApiProvider_CLINE + + updates := ProviderUpdatesPartial{ + ModelID: &modelID, + ModelInfo: modelInfo, + } + + return UpdateProviderPartial(ctx, manager, provider, updates, true) +} + +func applyDefaultClineModel(ctx context.Context, manager *task.Manager, modelInfo *cline.OpenRouterModelInfo) error { + if err := applyClineModelConfiguration(ctx, manager, DefaultClineModelID, modelInfo); err != nil { + return err + } + + if err := setWelcomeViewCompletedWithManager(ctx, manager); err != nil { + verboseLog("Warning: Failed to mark welcome view as completed: %v", err) + } + + return nil +} + +func setWelcomeViewCompletedWithManager(ctx context.Context, manager *task.Manager) error { + _, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true}) + return err +} diff --git a/cli/pkg/cli/auth/models_list_fetch.go b/cli/pkg/cli/auth/models_list_fetch.go new file mode 100644 index 00000000000..0cb641cf68a --- /dev/null +++ b/cli/pkg/cli/auth/models_list_fetch.go @@ -0,0 +1,156 @@ +package auth + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" + "golang.org/x/term" +) + +// FetchOpenRouterModels fetches available OpenRouter models from Cline Core +func FetchOpenRouterModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OpenRouterModelInfo, error) { + resp, err := manager.GetClient().Models.RefreshOpenRouterModelsRpc(ctx, &cline.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch OpenRouter models: %w", err) + } + return resp.Models, nil +} + +// FetchOcaModels fetches available Oca models from Cline Core +func FetchOcaModels(ctx context.Context, manager *task.Manager) (map[string]*cline.OcaModelInfo, error) { + resp, err := manager.GetClient().Models.RefreshOcaModels(ctx, &cline.StringRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch Oca models: %w", err) + } + return resp.Models, nil +} + +// ConvertOpenRouterModelsToInterface converts OpenRouter model map to generic interface map. +// This allows OpenRouter and Cline models to be used with the generic fetching utilities. +func ConvertOpenRouterModelsToInterface(models map[string]*cline.OpenRouterModelInfo) map[string]interface{} { + result := make(map[string]interface{}, len(models)) + for k, v := range models { + result[k] = v + } + return result +} + + +// FetchOpenAiModels fetches available OpenAI models from Cline Core +// Takes the API key and returns a list of model IDs +func FetchOpenAiModels(ctx context.Context, manager *task.Manager, baseURL, apiKey string) ([]string, error) { + req := &cline.OpenAiModelsRequest{ + BaseUrl: baseURL, + ApiKey: apiKey, + } + + resp, err := manager.GetClient().Models.RefreshOpenAiModels(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to fetch OpenAI models: %w", err) + } + return resp.Values, nil +} + +// FetchOllamaModels fetches available Ollama models from Cline Core +// Takes the base URL (empty string for default) and returns a list of model IDs +func FetchOllamaModels(ctx context.Context, manager *task.Manager, baseURL string) ([]string, error) { + req := &cline.StringRequest{ + Value: baseURL, + } + + resp, err := manager.GetClient().Models.GetOllamaModels(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to fetch Ollama models: %w", err) + } + return resp.Values, nil +} + +// DisplayModelSelectionMenu shows an interactive menu for selecting a model from a list. +// Models are displayed alphabetically. Uses model ID as the option value to avoid +// index-based bugs when list order changes. +// Returns the selected model ID. +func DisplayModelSelectionMenu(models []string, providerName string) (string, error) { + if len(models) == 0 { + return "", fmt.Errorf("no models available for selection") + } + + // Use model ID as the value (not index) to avoid positional coupling bugs + var selectedModel string + options := make([]huh.Option[string], len(models)) + for i, model := range models { + options[i] = huh.NewOption(model, model) + } + + title := fmt.Sprintf("Select a %s model", providerName) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(title). + Options(options...). + Height(calculateSelectHeight()). + Filtering(true). + Value(&selectedModel), + ), + ) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("failed to select model: %w", err) + } + + return selectedModel, nil +} + +// ConvertModelsMapToSlice converts a map of models to a sorted slice of model IDs. +// This is useful for displaying models in a consistent order in UI components. +func ConvertModelsMapToSlice(models map[string]interface{}) []string { + result := make([]string, 0, len(models)) + for modelID := range models { + result = append(result, modelID) + } + + // Sort alphabetically for consistent display + sort.Strings(result) + + return result +} + +// ConvertOcaModelsToInterface converts Oca model map to generic interface map. +// This allows Oca and Cline models to be used with the generic fetching utilities. +func ConvertOcaModelsToInterface(models map[string]*cline.OcaModelInfo) map[string]interface{} { + result := make(map[string]interface{}, len(models)) + for k, v := range models { + result[k] = v + } + return result +} + +// getTerminalHeight returns the terminal height (rows) +func getTerminalHeight() int { + _, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || height <= 0 { + return 25 // safe fallback for non-TTY or errors + } + return height +} + +// calculateSelectHeight computes appropriate height for Select component +// Reserves space for title, search UI, and margins +func calculateSelectHeight() int { + height := getTerminalHeight() + // Reserve ~10 rows for UI chrome (title, search, margins) + visibleRows := height - 10 + // Clamp between 8 (minimum usable) and 25 (maximum before unwieldy) + if visibleRows < 8 { + return 8 + } + if visibleRows > 25 { + return 25 + } + return visibleRows +} diff --git a/cli/pkg/cli/auth/models_list_static.go b/cli/pkg/cli/auth/models_list_static.go new file mode 100644 index 00000000000..400b75f6c49 --- /dev/null +++ b/cli/pkg/cli/auth/models_list_static.go @@ -0,0 +1,69 @@ +package auth + +import ( + "fmt" + "sort" + + "github.com/cline/cli/pkg/generated" + "github.com/cline/grpc-go/cline" +) + +// SupportsStaticModelList returns true if the provider has a predefined static model list +func SupportsStaticModelList(provider cline.ApiProvider) bool { + providerID := GetProviderIDForEnum(provider) + if providerID == "" { + return false + } + + // Check if this provider has static models defined + def, err := generated.GetProviderDefinition(providerID) + if err != nil { + return false + } + + // Return true if provider has models and isn't dynamic-only + // (Dynamic providers like OpenRouter/OpenAI/Ollama fetch from API) + return len(def.Models) > 0 && !def.HasDynamicModels +} + +// FetchStaticModels retrieves the static model list for a provider from generated definitions +// Returns a sorted list of model IDs and a map of model IDs to their info +func FetchStaticModels(provider cline.ApiProvider) ([]string, map[string]generated.ModelInfo, error) { + providerID := GetProviderIDForEnum(provider) + if providerID == "" { + return nil, nil, fmt.Errorf("unknown provider enum: %v", provider) + } + + def, err := generated.GetProviderDefinition(providerID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get provider definition: %w", err) + } + + if len(def.Models) == 0 { + return nil, nil, fmt.Errorf("no models defined for provider %s", providerID) + } + + // Extract model IDs and sort them + modelIDs := make([]string, 0, len(def.Models)) + for modelID := range def.Models { + modelIDs = append(modelIDs, modelID) + } + sort.Strings(modelIDs) + + return modelIDs, def.Models, nil +} + +// GetDefaultModelForProvider returns the default model ID for a provider if one is defined +func GetDefaultModelForProvider(provider cline.ApiProvider) string { + providerID := GetProviderIDForEnum(provider) + if providerID == "" { + return "" + } + + def, err := generated.GetProviderDefinition(providerID) + if err != nil { + return "" + } + + return def.DefaultModelID +} diff --git a/cli/pkg/cli/auth/providers_byo.go b/cli/pkg/cli/auth/providers_byo.go new file mode 100644 index 00000000000..daec5c88576 --- /dev/null +++ b/cli/pkg/cli/auth/providers_byo.go @@ -0,0 +1,181 @@ +package auth + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/cline/grpc-go/cline" +) + +// BYOProviderOption represents a selectable BYO (bring-your-own) provider option +type BYOProviderOption struct { + Name string + Provider cline.ApiProvider +} + +// GetBYOProviderList returns the list of supported BYO providers for CLI configuration. +// This list excludes Cline provider which is handled separately. +func GetBYOProviderList() []BYOProviderOption { + return []BYOProviderOption{ + {Name: "Anthropic", Provider: cline.ApiProvider_ANTHROPIC}, + {Name: "OpenAI Compatible", Provider: cline.ApiProvider_OPENAI}, + {Name: "OpenAI (Official)", Provider: cline.ApiProvider_OPENAI_NATIVE}, + {Name: "OpenRouter", Provider: cline.ApiProvider_OPENROUTER}, + {Name: "X AI (Grok)", Provider: cline.ApiProvider_XAI}, + {Name: "AWS Bedrock", Provider: cline.ApiProvider_BEDROCK}, + {Name: "Google Gemini", Provider: cline.ApiProvider_GEMINI}, + {Name: "Ollama", Provider: cline.ApiProvider_OLLAMA}, + {Name: "Cerebras", Provider: cline.ApiProvider_CEREBRAS}, + {Name: "Oracle Code Assist", Provider: cline.ApiProvider_OCA}, + } +} + +// SelectBYOProvider displays a menu for selecting a BYO provider. +func SelectBYOProvider() (cline.ApiProvider, error) { + providers := GetBYOProviderList() + var selectedIndex int + + options := make([]huh.Option[int], len(providers)+1) + for i, provider := range providers { + options[i] = huh.NewOption(provider.Name, i) + } + options[len(providers)] = huh.NewOption("(Cancel)", -1) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Select an API provider"). + Options(options...). + Value(&selectedIndex), + ), + ) + + if err := form.Run(); err != nil { + return 0, fmt.Errorf("failed to select provider: %w", err) + } + + if selectedIndex == -1 { + return 0, fmt.Errorf("provider selection cancelled") + } + + return providers[selectedIndex].Provider, nil +} + +// SupportsBYOModelFetching returns true if the provider supports fetching models dynamically +// from a remote API, or if it has a static list of predefined models. +// This is used to determine whether to show a model list before prompting for manual entry. +func SupportsBYOModelFetching(provider cline.ApiProvider) bool { + switch provider { + case cline.ApiProvider_OPENROUTER: + return true + case cline.ApiProvider_OPENAI: + return true + case cline.ApiProvider_OLLAMA: + return true + case cline.ApiProvider_OCA: + return true + } + + return SupportsStaticModelList(provider) +} + +// GetBYOProviderPlaceholder returns a placeholder model ID for manual entry based on provider. +func GetBYOProviderPlaceholder(provider cline.ApiProvider) string { + switch provider { + case cline.ApiProvider_ANTHROPIC: + return "e.g., claude-sonnet-4-5-20250929" + case cline.ApiProvider_OPENAI: + return "e.g., openai/gpt-oss-120b" + case cline.ApiProvider_OPENAI_NATIVE: + return "e.g., gpt-5-2025-08-07" + case cline.ApiProvider_OPENROUTER: + return "e.g., google/gemini-2.0-flash-exp:free" + case cline.ApiProvider_XAI: + return "e.g., grok-code-fast-1" + case cline.ApiProvider_BEDROCK: + return "e.g., anthropic.claude-sonnet-4-5-20250929-v1:0" + case cline.ApiProvider_GEMINI: + return "e.g., gemini-2.5-pro" + case cline.ApiProvider_OLLAMA: + return "e.g., qwen3-coder:30b" + case cline.ApiProvider_CEREBRAS: + return "e.g., gpt-oss-120b" + case cline.ApiProvider_OCA: + return "e.g., oca/llama4" + default: + return "Enter model ID" + } +} + +// GetBYOAPIKeyFieldConfig returns field configuration for API key input based on provider. +type APIKeyFieldConfig struct { + Title string + EchoMode huh.EchoMode + IsRequired bool +} + +// GetBYOAPIKeyFieldConfig returns the configuration for the API key field based on provider. +func GetBYOAPIKeyFieldConfig(provider cline.ApiProvider) APIKeyFieldConfig { + if provider == cline.ApiProvider_OLLAMA { + return APIKeyFieldConfig{ + Title: "Base URL (optional, press Enter for default)", + EchoMode: huh.EchoModeNormal, + IsRequired: false, + } + } + + return APIKeyFieldConfig{ + Title: "API Key", + EchoMode: huh.EchoModePassword, + IsRequired: true, + } +} + +// PromptForAPIKey prompts the user to enter an API key (or base URL for Ollama). +// For OpenAI (Compatible) provider, also prompts for an optional base URL. +func PromptForAPIKey(provider cline.ApiProvider) (string, string, error) { + var apiKey string + config := GetBYOAPIKeyFieldConfig(provider) + + apiKeyField := huh.NewInput(). + Title(config.Title). + EchoMode(config.EchoMode). + Value(&apiKey) + + if config.IsRequired { + apiKeyField = apiKeyField.Validate(func(s string) error { + if s == "" { + return fmt.Errorf("API key cannot be empty") + } + return nil + }) + } + + form := huh.NewForm(huh.NewGroup(apiKeyField)) + + if err := form.Run(); err != nil { + return "", "", fmt.Errorf("failed to get API key: %w", err) + } + + // For OpenAI (Compatible) provider, prompt for base URL + if provider == cline.ApiProvider_OPENAI { + var baseURL string + baseURLForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Base URL (optional, for OpenAI-compatible providers)"). + Placeholder("e.g., https://api.example.com/v1"). + Value(&baseURL). + Description("Press Enter to skip if using standard OpenAI API"), + ), + ) + + if err := baseURLForm.Run(); err != nil { + return "", "", fmt.Errorf("failed to get base URL: %w", err) + } + + return apiKey, baseURL, nil + } + + return apiKey, "", nil +} diff --git a/cli/pkg/cli/auth/providers_list.go b/cli/pkg/cli/auth/providers_list.go new file mode 100644 index 00000000000..3b138222407 --- /dev/null +++ b/cli/pkg/cli/auth/providers_list.go @@ -0,0 +1,499 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +// ProviderDisplay represents a configured provider for display purposes +type ProviderDisplay struct { + Mode string // "Plan" or "Act" + Provider cline.ApiProvider // Provider enum + ModelID string // Model identifier + HasAPIKey bool // Whether an API key is configured (never show actual key) + BaseURL string // Base URL for providers like Ollama (can be shown publicly) +} + +// ProviderListResult holds the parsed provider configuration from state +type ProviderListResult struct { + PlanProvider *ProviderDisplay + ActProvider *ProviderDisplay + apiConfig map[string]interface{} // Store the raw apiConfig for scanning all providers +} + +// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state +func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) { + if global.Config.Verbose { + fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core") + } + + // Get latest state from Cline Core + state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get state: %w", err) + } + + stateJSON := state.StateJson + + if global.Config.Verbose { + fmt.Printf("[DEBUG] Retrieved state, parsing JSON (length: %d)\n", len(stateJSON)) + } + + // Parse state_json as map[string]interface{} + var stateData map[string]interface{} + if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil { + return nil, fmt.Errorf("failed to parse state JSON: %w", err) + } + + if global.Config.Verbose { + fmt.Printf("[DEBUG] Parsed state data with %d keys\n", len(stateData)) + } + + // Extract apiConfiguration object from state + apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{}) + if !ok { + if global.Config.Verbose { + fmt.Println("[DEBUG] No apiConfiguration found in state") + } + return &ProviderListResult{ + apiConfig: make(map[string]interface{}), + }, nil + } + + if global.Config.Verbose { + fmt.Printf("[DEBUG] Found apiConfiguration with %d keys\n", len(apiConfig)) + } + + // Extract plan mode configuration + planProvider := extractProviderFromState(apiConfig, "plan") + if global.Config.Verbose && planProvider != nil { + fmt.Printf("[DEBUG] Plan mode: provider=%v, model=%s\n", planProvider.Provider, planProvider.ModelID) + } + + // Extract act mode configuration + actProvider := extractProviderFromState(apiConfig, "act") + if global.Config.Verbose && actProvider != nil { + fmt.Printf("[DEBUG] Act mode: provider=%v, model=%s\n", actProvider.Provider, actProvider.ModelID) + } + + return &ProviderListResult{ + PlanProvider: planProvider, + ActProvider: actProvider, + apiConfig: apiConfig, + }, nil +} + +// GetAllReadyProviders returns all providers that have both a model and API key configured +func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay { + if r.apiConfig == nil { + return []*ProviderDisplay{} + } + + var readyProviders []*ProviderDisplay + seenProviders := make(map[cline.ApiProvider]bool) + + // Check all possible providers + allProviders := []cline.ApiProvider{ + cline.ApiProvider_CLINE, + cline.ApiProvider_ANTHROPIC, + cline.ApiProvider_OPENAI, + cline.ApiProvider_OPENAI_NATIVE, + cline.ApiProvider_OPENROUTER, + cline.ApiProvider_XAI, + cline.ApiProvider_BEDROCK, + cline.ApiProvider_GEMINI, + cline.ApiProvider_OLLAMA, + cline.ApiProvider_CEREBRAS, + cline.ApiProvider_OCA, + } + + // Check each provider to see if it's ready to use + // We use "plan" mode to check, since both plan and act should have the same providers configured + for _, provider := range allProviders { + // Skip if we've already seen this provider + if seenProviders[provider] { + continue + } + + // Check if this provider has a model configured + modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider) + + // Determine if credentials exist + hasCreds := checkAPIKeyExists(r.apiConfig, provider) + + // Determine readiness: OCA uses auth state presence; others need creds and model + if provider == cline.ApiProvider_OCA { + state, _ := GetLatestOCAState(context.Background(), 2 *time.Second) + if state == nil || state.User == nil { + continue + } + } else { + // Provider is not ready unless it has credentials AND a model configured + if !hasCreds || modelID == "" { + continue + } + } + + // Get base URL for Ollama + baseURL := "" + if provider == cline.ApiProvider_OLLAMA { + if url, ok := r.apiConfig["ollamaBaseUrl"].(string); ok { + baseURL = url + } + } + + // This provider is ready to use + readyProviders = append(readyProviders, &ProviderDisplay{ + Mode: "Ready", + Provider: provider, + ModelID: modelID, + HasAPIKey: checkAPIKeyExists(r.apiConfig, provider), + BaseURL: baseURL, + }) + seenProviders[provider] = true + } + + return readyProviders +} + +// extractProviderFromState extracts provider configuration for specific plan/act mode +func extractProviderFromState(stateData map[string]interface{}, mode string) *ProviderDisplay { + // Build key names based on mode + providerKey := mode + "ModeApiProvider" + + // Extract provider string from state + providerStr, ok := stateData[providerKey].(string) + if !ok || providerStr == "" { + if global.Config.Verbose { + fmt.Printf("[DEBUG] No provider configured for %s mode\n", mode) + } + return nil + } + + // Map provider string to enum + provider, ok := mapProviderStringToEnum(providerStr) + if !ok { + if global.Config.Verbose { + fmt.Printf("[DEBUG] Unknown provider type: %s\n", providerStr) + } + return nil + } + + // Get provider-specific model ID + modelID := getProviderSpecificModelID(stateData, mode, provider) + + // Check if API key exists + hasAPIKey := checkAPIKeyExists(stateData, provider) + + // Get base URL for Ollama (can be shown publicly) + baseURL := "" + if provider == cline.ApiProvider_OLLAMA { + if url, ok := stateData["ollamaBaseUrl"].(string); ok { + baseURL = url + } + } + + return &ProviderDisplay{ + Mode: capitalizeMode(mode), + Provider: provider, + ModelID: modelID, + HasAPIKey: hasAPIKey, + BaseURL: baseURL, + } +} + +// mapProviderStringToEnum converts provider string from state to ApiProvider enum +// Returns (provider, ok) where ok is false if the provider is unknown +func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) { + // Map string values to enum values + switch providerStr { + case "anthropic": + return cline.ApiProvider_ANTHROPIC, true + case "openai-compatible": // internal name is 'openai', but this is actually the openai-compatible provider + return cline.ApiProvider_OPENAI, true + case "openai", "openai-native": // This is the native, official Open AI provider + return cline.ApiProvider_OPENAI_NATIVE, true + case "openrouter": + return cline.ApiProvider_OPENROUTER, true + case "xai": + return cline.ApiProvider_XAI, true + case "bedrock": + return cline.ApiProvider_BEDROCK, true + case "gemini": + return cline.ApiProvider_GEMINI, true + case "ollama": + return cline.ApiProvider_OLLAMA, true + case "cerebras": + return cline.ApiProvider_CEREBRAS, true + case "cline": + return cline.ApiProvider_CLINE, true + case "oca": + return cline.ApiProvider_OCA, true + default: + return cline.ApiProvider_ANTHROPIC, false // Return 0 value with false + } +} + +// GetProviderIDForEnum converts a provider enum to the provider ID string +// This is the inverse of mapProviderStringToEnum and is used for provider definitions +func GetProviderIDForEnum(provider cline.ApiProvider) string { + switch provider { + case cline.ApiProvider_ANTHROPIC: + return "anthropic" + case cline.ApiProvider_OPENAI: + return "openai-compatible" + case cline.ApiProvider_OPENAI_NATIVE: + return "openai-native" + case cline.ApiProvider_OPENROUTER: + return "openrouter" + case cline.ApiProvider_XAI: + return "xai" + case cline.ApiProvider_BEDROCK: + return "bedrock" + case cline.ApiProvider_GEMINI: + return "gemini" + case cline.ApiProvider_OLLAMA: + return "ollama" + case cline.ApiProvider_CEREBRAS: + return "cerebras" + case cline.ApiProvider_CLINE: + return "cline" + case cline.ApiProvider_OCA: + return "oca" + default: + return "" + } +} + +// getProviderSpecificModelID gets the provider-specific model ID field from state +func getProviderSpecificModelID(stateData map[string]interface{}, mode string, provider cline.ApiProvider) string { + modelKey, err := GetModelIDFieldName(provider, mode) + if err != nil { + if global.Config.Verbose { + fmt.Printf("[DEBUG] Error getting model ID field name: %v\n", err) + } + return "" + } + + if global.Config.Verbose { + fmt.Printf("[DEBUG] Looking for model ID in key: %s\n", modelKey) + } + + // Extract model ID from state + modelID, _ := stateData[modelKey].(string) + return modelID +} + +// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key) +func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool { + // Get field mapping from centralized function + fields, err := GetProviderFields(provider) + if err != nil { + return false + } + + keyField := fields.APIKeyField + + // Check if the key exists and is not empty + if value, ok := stateData[keyField]; ok { + if str, ok := value.(string); ok && str != "" { + return true + } + } + + return false +} + +// capitalizeMode capitalizes the mode string for display +func capitalizeMode(mode string) string { + if len(mode) == 0 { + return mode + } + return strings.ToUpper(mode[:1]) + mode[1:] +} + +// GetProviderDisplayName returns a user-friendly name for the provider +func GetProviderDisplayName(provider cline.ApiProvider) string { + switch provider { + case cline.ApiProvider_ANTHROPIC: + return "Anthropic" + case cline.ApiProvider_OPENAI: + return "OpenAI Compatible" + case cline.ApiProvider_OPENAI_NATIVE: + return "OpenAI (Official)" + case cline.ApiProvider_OPENROUTER: + return "OpenRouter" + case cline.ApiProvider_XAI: + return "X AI (Grok)" + case cline.ApiProvider_BEDROCK: + return "AWS Bedrock" + case cline.ApiProvider_GEMINI: + return "Google Gemini" + case cline.ApiProvider_OLLAMA: + return "Ollama" + case cline.ApiProvider_CEREBRAS: + return "Cerebras" + case cline.ApiProvider_CLINE: + return "Cline (Official)" + case cline.ApiProvider_OCA: + return "Oracle Code Assist" + default: + return "Unknown" + } +} + +// FormatProviderList formats the complete provider list for console display +// This now shows ALL providers that have both a model and API key configured +func FormatProviderList(result *ProviderListResult) string { + var output strings.Builder + + output.WriteString("\n=== Configured API Providers ===\n\n") + + // Get the currently active provider + var activeProvider cline.ApiProvider + var activeProviderSet bool + if result.ActProvider != nil { + activeProvider = result.ActProvider.Provider + activeProviderSet = true + } + + // Get all ready-to-use providers (those with both API key and model configured) + readyProviders := result.GetAllReadyProviders() + + if len(readyProviders) == 0 { + output.WriteString(" No providers ready to use.\n") + output.WriteString(" A provider is ready when it has both a model and API key configured.\n") + output.WriteString(" Use 'Configure a new provider' to configure one.\n\n") + } else { + //output.WriteString(fmt.Sprintf(" %d provider(s) ready to use:\n\n", len(readyProviders))) + + for _, display := range readyProviders { + // Check if this is the active provider + isActive := activeProviderSet && display.Provider == activeProvider + + if isActive { + output.WriteString(fmt.Sprintf(" ✓ %s (ACTIVE)\n", GetProviderDisplayName(display.Provider))) + } else { + output.WriteString(fmt.Sprintf(" • %s\n", GetProviderDisplayName(display.Provider))) + } + + output.WriteString(fmt.Sprintf(" Model: %s\n", display.ModelID)) + + // Show status based on provider type + if display.Provider == cline.ApiProvider_OLLAMA { + if display.BaseURL != "" { + output.WriteString(fmt.Sprintf(" Base URL: %s\n", display.BaseURL)) + } else { + output.WriteString(" Base URL: (default)\n") + } + } else if display.Provider == cline.ApiProvider_CLINE || display.Provider == cline.ApiProvider_OCA { + output.WriteString(" Status: Authenticated\n") + } else { + output.WriteString(" API Key: Configured\n") + } + + output.WriteString("\n") + } + } + + output.WriteString("================================\n") + + return output.String() +} + +// DetectAllConfiguredProviders scans the state to find all providers that have API keys configured. +// This allows switching between multiple providers even when only one is currently active. +func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([]cline.ApiProvider, error) { + verboseLog("[DEBUG] Detecting all configured providers...") + + // Get latest state from Cline Core + state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get state: %w", err) + } + + stateJSON := state.StateJson + + // Parse state_json as map[string]interface{} + var stateData map[string]interface{} + if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil { + return nil, fmt.Errorf("failed to parse state JSON: %w", err) + } + + // Extract apiConfiguration object from state + apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{}) + if !ok { + verboseLog("[DEBUG] No apiConfiguration found in state") + verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData)) + return []cline.ApiProvider{}, nil + } + + verboseLog("[DEBUG] apiConfiguration keys: %v", getMapKeys(apiConfig)) + + var configuredProviders []cline.ApiProvider + + // Check for Cline provider (uses authentication instead of API key) + if IsAuthenticated(ctx) { + configuredProviders = append(configuredProviders, cline.ApiProvider_CLINE) + verboseLog("[DEBUG] Cline provider is authenticated") + } + + // Check OCA provider via global auth subscription (state presence) + if state, _ := GetLatestOCAState(context.Background(), 2*time.Second); state != nil && state.User != nil { + configuredProviders = append(configuredProviders, cline.ApiProvider_OCA) + verboseLog("[DEBUG] OCA provider has active auth state") + } + + // Check each BYO provider for API key presence + providersToCheck := []struct { + provider cline.ApiProvider + keyField string + }{ + {cline.ApiProvider_ANTHROPIC, "apiKey"}, + {cline.ApiProvider_OPENAI, "openAiApiKey"}, + {cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"}, + {cline.ApiProvider_OPENROUTER, "openRouterApiKey"}, + {cline.ApiProvider_XAI, "xaiApiKey"}, + {cline.ApiProvider_BEDROCK, "awsAccessKey"}, + {cline.ApiProvider_GEMINI, "geminiApiKey"}, + {cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key + {cline.ApiProvider_CEREBRAS, "cerebrasApiKey"}, + } + + for _, providerCheck := range providersToCheck { + verboseLog("[DEBUG] Checking for %s key: %s", GetProviderDisplayName(providerCheck.provider), providerCheck.keyField) + if value, ok := apiConfig[providerCheck.keyField]; ok { + verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "") + if str, ok := value.(string); ok && str != "" { + configuredProviders = append(configuredProviders, providerCheck.provider) + verboseLog("[DEBUG] ✓ Provider %s is configured", GetProviderDisplayName(providerCheck.provider)) + } + } else { + verboseLog("[DEBUG] Key %s not found", providerCheck.keyField) + } + } + + + verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders)) + for _, p := range configuredProviders { + verboseLog("[DEBUG] - %s", GetProviderDisplayName(p)) + } + + return configuredProviders, nil +} + +// getMapKeys returns the keys of a map for debugging +func getMapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/cli/pkg/cli/auth/update_api_configurations.go b/cli/pkg/cli/auth/update_api_configurations.go new file mode 100644 index 00000000000..e99ee8bc382 --- /dev/null +++ b/cli/pkg/cli/auth/update_api_configurations.go @@ -0,0 +1,620 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +// updateApiConfigurationPartial is a helper that calls the gRPC method with optional verbose logging. +// This replaces the Manager.updateApiConfigurationPartial method to keep auth-specific code in the auth package. +func updateApiConfigurationPartial(ctx context.Context, manager *task.Manager, request *cline.UpdateApiConfigurationPartialRequest) error { + if global.Config.Verbose { + fmt.Println("[DEBUG] Updating API configuration (partial)") + if request.UpdateMask != nil && len(request.UpdateMask.Paths) > 0 { + fmt.Printf("[DEBUG] Field mask paths: %v\n", request.UpdateMask.Paths) + } + if request.ApiConfiguration != nil { + apiConfig := request.ApiConfiguration + if apiConfig.PlanModeApiProvider != nil { + fmt.Printf("[DEBUG] Plan mode provider: %s\n", *apiConfig.PlanModeApiProvider) + } + if apiConfig.ActModeApiProvider != nil { + fmt.Printf("[DEBUG] Act mode provider: %s\n", *apiConfig.ActModeApiProvider) + } + } + } + + // Call the Models service to update API configuration + _, err := manager.GetClient().Models.UpdateApiConfigurationPartial(ctx, request) + if err != nil { + return fmt.Errorf("failed to update API configuration (partial): %w", err) + } + + if global.Config.Verbose { + fmt.Println("[DEBUG] API configuration updated successfully (partial)") + } + + return nil +} + +// ProviderFields defines all the field names associated with a specific provider +type ProviderFields struct { + APIKeyField string // API key field name (e.g., "apiKey", "openAiApiKey") + BaseURLField string // Base URL field name (optional, empty if not applicable) + PlanModeModelIDField string // Plan mode model ID field (e.g., "planModeApiModelId") + ActModeModelIDField string // Act mode model ID field (e.g., "actModeApiModelId") + PlanModeModelInfoField string // Plan mode model info field (optional, empty if not applicable) + ActModeModelInfoField string // Act mode model info field (optional, empty if not applicable) + // Provider-specific additional model ID fields + PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId" + ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId" +} + +// GetProviderFields returns the field mapping for a given provider +func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) { + switch provider { + case cline.ApiProvider_ANTHROPIC: + return ProviderFields{ + APIKeyField: "apiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + }, nil + + case cline.ApiProvider_OPENAI: + return ProviderFields{ + APIKeyField: "openAiApiKey", + BaseURLField: "openAiBaseUrl", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeProviderSpecificModelIDField: "planModeOpenAiModelId", + ActModeProviderSpecificModelIDField: "actModeOpenAiModelId", + }, nil + + case cline.ApiProvider_OPENROUTER: + return ProviderFields{ + APIKeyField: "openRouterApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeModelInfoField: "planModeOpenRouterModelInfo", + ActModeModelInfoField: "actModeOpenRouterModelInfo", + PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId", + ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId", + }, nil + + case cline.ApiProvider_XAI: + return ProviderFields{ + APIKeyField: "xaiApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + }, nil + + case cline.ApiProvider_BEDROCK: + return ProviderFields{ + APIKeyField: "awsAccessKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeProviderSpecificModelIDField: "planModeAwsBedrockCustomModelBaseId", + ActModeProviderSpecificModelIDField: "actModeAwsBedrockCustomModelBaseId", + }, nil + + case cline.ApiProvider_GEMINI: + return ProviderFields{ + APIKeyField: "geminiApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + }, nil + + case cline.ApiProvider_OPENAI_NATIVE: + return ProviderFields{ + APIKeyField: "openAiNativeApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + }, nil + + case cline.ApiProvider_OLLAMA: + return ProviderFields{ + APIKeyField: "ollamaBaseUrl", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeProviderSpecificModelIDField: "planModeOllamaModelId", + ActModeProviderSpecificModelIDField: "actModeOllamaModelId", + }, nil + + case cline.ApiProvider_CEREBRAS: + return ProviderFields{ + APIKeyField: "cerebrasApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + }, nil + + case cline.ApiProvider_CLINE: + return ProviderFields{ + APIKeyField: "clineApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeModelInfoField: "planModeOpenRouterModelInfo", + ActModeModelInfoField: "actModeOpenRouterModelInfo", + PlanModeProviderSpecificModelIDField: "planModeOpenRouterModelId", + ActModeProviderSpecificModelIDField: "actModeOpenRouterModelId", + }, nil + + case cline.ApiProvider_OCA: + return ProviderFields{ + APIKeyField: "ocaApiKey", + PlanModeModelIDField: "planModeApiModelId", + ActModeModelIDField: "actModeApiModelId", + PlanModeModelInfoField: "planModeOcaModelInfo", + ActModeModelInfoField: "actModeOcaModelInfo", + PlanModeProviderSpecificModelIDField: "planModeOcaModelId", + ActModeProviderSpecificModelIDField: "actModeOcaModelId", + }, nil + + default: + return ProviderFields{}, fmt.Errorf("unsupported provider: %v", provider) + } +} + +// ProviderUpdatesPartial defines optional fields for partial provider updates +// Uses pointers to distinguish between "not provided" and "set to empty" +type ProviderUpdatesPartial struct { + ModelID *string // New model ID (optional) + APIKey *string // New API key (optional) + ModelInfo interface{} // New model info (optional, provider-specific) + BaseURL *string // New base URL (optional, e.g., for OCA, Ollama) + RefreshToken *string // New refresh token (optional, e.g., for OCA) + Mode *string // New mode (optional, e.g., "internal" or "external" for OCA) +} + +// GetModelIDFieldName returns the appropriate model ID field name for a provider and mode. +// This helper centralizes the logic for determining whether to use provider-specific +// or generic model ID fields. +func GetModelIDFieldName(provider cline.ApiProvider, mode string) (string, error) { + fields, err := GetProviderFields(provider) + if err != nil { + return "", err + } + + if mode == "plan" { + // Use provider-specific field if available, otherwise use generic field + if fields.PlanModeProviderSpecificModelIDField != "" { + return fields.PlanModeProviderSpecificModelIDField, nil + } + return fields.PlanModeModelIDField, nil + } + + // Act mode + if fields.ActModeProviderSpecificModelIDField != "" { + return fields.ActModeProviderSpecificModelIDField, nil + } + return fields.ActModeModelIDField, nil +} + +// buildProviderFieldMask builds a list of camelCase field paths for the field mask. +// When includeProviderEnums is true, the provider enum fields are included (for setting active provider). +// When false, only the data fields are included (for configuring without activating). +func buildProviderFieldMask(fields ProviderFields, includeAPIKey bool, includeModelID bool, includeModelInfo bool, includeBaseURL bool, includeProviderEnums bool) []string { + var fieldPaths []string + + // Include provider enums if requested (used when setting active provider) + if includeProviderEnums { + fieldPaths = append(fieldPaths, "planModeApiProvider", "actModeApiProvider") + } + + // Add API key field if requested + if includeAPIKey { + fieldPaths = append(fieldPaths, fields.APIKeyField) + // Special case: Bedrock also needs secret key + if fields.APIKeyField == "awsAccessKey" { + fieldPaths = append(fieldPaths, "awsSecretKey") + } + } + + // Add base URL field if requested and applicable + if includeBaseURL && fields.BaseURLField != "" { + fieldPaths = append(fieldPaths, fields.BaseURLField) + } + + // Add model ID fields if requested + if includeModelID { + // Only include provider-specific fields if they exist, otherwise use generic fields + if fields.PlanModeProviderSpecificModelIDField != "" { + // Provider has specific fields - use ONLY those + fieldPaths = append(fieldPaths, fields.PlanModeProviderSpecificModelIDField) + fieldPaths = append(fieldPaths, fields.ActModeProviderSpecificModelIDField) + } else { + // Provider uses generic fields - update those + fieldPaths = append(fieldPaths, fields.PlanModeModelIDField) + fieldPaths = append(fieldPaths, fields.ActModeModelIDField) + } + } + + // Add model info fields if requested and applicable + if includeModelInfo && fields.PlanModeModelInfoField != "" { + fieldPaths = append(fieldPaths, fields.PlanModeModelInfoField) + fieldPaths = append(fieldPaths, fields.ActModeModelInfoField) + } + + return fieldPaths +} + +// setAPIKeyField sets the appropriate API key field in the config based on the field name +func setAPIKeyField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) { + switch fieldName { + case "apiKey": + apiConfig.ApiKey = value + case "openAiApiKey": + apiConfig.OpenAiApiKey = value + case "openAiNativeApiKey": + apiConfig.OpenAiNativeApiKey = value + case "openRouterApiKey": + apiConfig.OpenRouterApiKey = value + case "xaiApiKey": + apiConfig.XaiApiKey = value + case "awsAccessKey": + apiConfig.AwsAccessKey = value + case "geminiApiKey": + apiConfig.GeminiApiKey = value + case "ollamaBaseUrl": + apiConfig.OllamaBaseUrl = value + case "cerebrasApiKey": + apiConfig.CerebrasApiKey = value + case "clineApiKey": + apiConfig.ClineApiKey = value + case "ocaApiKey": + apiConfig.OcaApiKey = value + } +} + +// setProviderSpecificModelID sets the appropriate provider-specific model ID fields when possible +func setProviderSpecificModelID(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) { + switch fieldName { + case "planModeOpenAiModelId": + apiConfig.PlanModeOpenAiModelId = value + apiConfig.ActModeOpenAiModelId = value + case "planModeOpenRouterModelId": + apiConfig.PlanModeOpenRouterModelId = value + apiConfig.ActModeOpenRouterModelId = value + case "planModeOllamaModelId": + apiConfig.PlanModeOllamaModelId = value + apiConfig.ActModeOllamaModelId = value + case "planModeAwsBedrockCustomModelBaseId": + apiConfig.PlanModeAwsBedrockCustomModelBaseId = value + apiConfig.ActModeAwsBedrockCustomModelBaseId = value + case "planModeOcaModelId": + apiConfig.PlanModeOcaModelId = value + apiConfig.ActModeOcaModelId = value + } +} + +// AddProviderPartial configures a new provider with all necessary fields using partial updates. +func AddProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, modelID string, apiKey string, baseURL string, modelInfo interface{}) error { + // Get field mapping for this provider + fields, err := GetProviderFields(provider) + if err != nil { + return err + } + + // Build a ModelsApiConfiguration with only the relevant provider fields set + apiConfig := &cline.ModelsApiConfiguration{} + + // Set API key field + if apiKey != "" || fields.APIKeyField != "ollamaBaseUrl" { + setAPIKeyField(apiConfig, fields.APIKeyField, proto.String(apiKey)) + } + + // Set base URL field if provided and applicable + includeBaseURL := false + if baseURL != "" && fields.BaseURLField != "" { + setBaseURLField(apiConfig, fields.BaseURLField, proto.String(baseURL)) + includeBaseURL = true + } + + // Set model ID fields + apiConfig.PlanModeApiModelId = proto.String(modelID) + apiConfig.ActModeApiModelId = proto.String(modelID) + + // Set provider-specific model ID fields if applicable + if fields.PlanModeProviderSpecificModelIDField != "" { + setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, proto.String(modelID)) + } + + // Set model info if applicable and provided + if fields.PlanModeModelInfoField != "" && modelInfo != nil { + if openRouterInfo, ok := modelInfo.(*cline.OpenRouterModelInfo); ok { + apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo + apiConfig.ActModeOpenRouterModelInfo = openRouterInfo + } + } + + // Build field mask including all fields we're setting (without provider enums) + includeModelInfo := fields.PlanModeModelInfoField != "" && modelInfo != nil + fieldPaths := buildProviderFieldMask(fields, true, true, includeModelInfo, includeBaseURL, false) + + // Create field mask + fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths} + + // Apply the partial update + request := &cline.UpdateApiConfigurationPartialRequest{ + ApiConfiguration: apiConfig, + UpdateMask: fieldMask, + } + + if err := updateApiConfigurationPartial(ctx, manager, request); err != nil { + return fmt.Errorf("failed to update API configuration: %w", err) + } + + return nil +} + +// UpdateProviderPartial updates specific fields for an existing provider using partial updates. +// If setAsActive is true, this will also set the provider as the active provider for both Plan and Act modes. +func UpdateProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider, updates ProviderUpdatesPartial, setAsActive bool) error { + // Get field mapping for this provider + fields, err := GetProviderFields(provider) + if err != nil { + return err + } + + // Build a ModelsApiConfiguration with only the fields being updated + apiConfig := &cline.ModelsApiConfiguration{} + + // Set provider enum for BOTH Plan and Act modes if setAsActive is true + if setAsActive { + apiConfig.PlanModeApiProvider = &provider + apiConfig.ActModeApiProvider = &provider + } + + // Track what we're updating for field mask + includeAPIKey := updates.APIKey != nil + includeModelID := updates.ModelID != nil + includeModelInfo := updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" + + // Update API key if provided + if updates.APIKey != nil { + setAPIKeyField(apiConfig, fields.APIKeyField, updates.APIKey) + } + + // Update model ID if provided + if updates.ModelID != nil { + // Only set provider-specific fields if they exist, otherwise use generic fields + if fields.PlanModeProviderSpecificModelIDField != "" { + setProviderSpecificModelID(apiConfig, fields.PlanModeProviderSpecificModelIDField, updates.ModelID) + } else { + // Provider uses generic fields - set those + apiConfig.PlanModeApiModelId = updates.ModelID + apiConfig.ActModeApiModelId = updates.ModelID + } + } + + // Update model info if provided + if updates.ModelInfo != nil && fields.PlanModeModelInfoField != "" { + if openRouterInfo, ok := updates.ModelInfo.(*cline.OpenRouterModelInfo); ok { + apiConfig.PlanModeOpenRouterModelInfo = openRouterInfo + apiConfig.ActModeOpenRouterModelInfo = openRouterInfo + } + } + + // Build field mask for only the fields being updated + fieldPaths := buildProviderFieldMask(fields, includeAPIKey, includeModelID, includeModelInfo, false, setAsActive) + + // Create field mask + fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths} + + // Apply the partial update + request := &cline.UpdateApiConfigurationPartialRequest{ + ApiConfiguration: apiConfig, + UpdateMask: fieldMask, + } + + if err := updateApiConfigurationPartial(ctx, manager, request); err != nil { + return fmt.Errorf("failed to update API configuration: %w", err) + } + + return nil +} + +// RemoveProviderPartial removes a provider by clearing its API key using partial updates +func RemoveProviderPartial(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error { + // Get field mapping for this provider + fields, err := GetProviderFields(provider) + if err != nil { + return err + } + + // Build an EMPTY ModelsApiConfiguration (or one with empty API key field) + // Fields in the mask without values will be cleared + apiConfig := &cline.ModelsApiConfiguration{} + + // Build field mask with only the API key field(s) + // For Bedrock, include both access key and secret key + fieldPaths := []string{fields.APIKeyField} + if provider == cline.ApiProvider_BEDROCK { + fieldPaths = append(fieldPaths, "awsSecretKey") + } + + // Create field mask + fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths} + + // Apply the partial update (clearing API key by including in mask without value) + request := &cline.UpdateApiConfigurationPartialRequest{ + ApiConfiguration: apiConfig, + UpdateMask: fieldMask, + } + + if err := updateApiConfigurationPartial(ctx, manager, request); err != nil { + return fmt.Errorf("failed to update API configuration: %w", err) + } + + return nil +} + +// setBaseURLField sets the appropriate base URL field in the config based on the field name +func setBaseURLField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) { + switch fieldName { + case "ocaBaseUrl": + apiConfig.OcaBaseUrl = value + case "ollamaBaseUrl": + apiConfig.OllamaBaseUrl = value + case "openAiBaseUrl": + apiConfig.OpenAiBaseUrl = value + case "geminiBaseUrl": + apiConfig.GeminiBaseUrl = value + case "liteLlmBaseUrl": + apiConfig.LiteLlmBaseUrl = value + case "anthropicBaseUrl": + apiConfig.AnthropicBaseUrl = value + case "requestyBaseUrl": + apiConfig.RequestyBaseUrl = value + case "lmStudioBaseUrl": + apiConfig.LmStudioBaseUrl = value + case "oca": + apiConfig.OcaBaseUrl = value + } +} + +// setRefreshTokenField sets the appropriate refresh token field in the config +func setRefreshTokenField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) { + switch fieldName { + case "ocaRefreshToken": + apiConfig.OcaRefreshToken = value + } +} + +// setModeField sets the appropriate mode field in the config +func setModeField(apiConfig *cline.ModelsApiConfiguration, fieldName string, value *string) { + switch fieldName { + case "ocaMode": + apiConfig.OcaMode = value + } +} + +// BedrockOptionalFields holds optional configuration fields for AWS Bedrock +type BedrockOptionalFields struct { + SessionToken *string // Optional: AWS session token for temporary credentials + Region *string // Optional: AWS region + UseCrossRegionInference *bool // Optional: Enable cross-region inference + UseGlobalInference *bool // Optional: Use global inference endpoint + UsePromptCache *bool // Optional: Enable prompt caching + Authentication *string // Optional: Authentication method + UseProfile *bool // Optional: Use AWS profile + Profile *string // Optional: AWS profile name + Endpoint *string // Optional: Custom endpoint URL +} + +// OcaOptionalFields holds optional configuration fields for Oracle Code Assist +type OcaOptionalFields struct { + BaseURL *string // Optional: Base URL + Mode *string // Optional: Mode ("internal" or "external") +} + +// setBedrockOptionalFields sets optional Bedrock-specific fields in the API configuration +func setBedrockOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *BedrockOptionalFields) { + if fields == nil { + return + } + + if fields.SessionToken != nil { + apiConfig.AwsSessionToken = fields.SessionToken + } + if fields.Region != nil { + apiConfig.AwsRegion = fields.Region + } + if fields.UseCrossRegionInference != nil { + apiConfig.AwsUseCrossRegionInference = fields.UseCrossRegionInference + } + if fields.UseGlobalInference != nil { + apiConfig.AwsUseGlobalInference = fields.UseGlobalInference + } + if fields.UsePromptCache != nil { + apiConfig.AwsBedrockUsePromptCache = fields.UsePromptCache + } + if fields.Authentication != nil { + apiConfig.AwsAuthentication = fields.Authentication + } + if fields.UseProfile != nil { + apiConfig.AwsUseProfile = fields.UseProfile + } + if fields.Profile != nil { + apiConfig.AwsProfile = fields.Profile + } + if fields.Endpoint != nil { + apiConfig.AwsBedrockEndpoint = fields.Endpoint + } +} + +// setOcaOptionalFields sets optional Oca-specific fields in the API configuration +func setOcaOptionalFields(apiConfig *cline.ModelsApiConfiguration, fields *OcaOptionalFields) { + if fields == nil { + return + } + + if fields.Mode != nil { + apiConfig.OcaMode = fields.Mode + } + if fields.BaseURL != nil { + apiConfig.OcaBaseUrl = fields.BaseURL + } +} + +// buildBedrockOptionalFieldMask builds field mask paths for Bedrock optional fields that have values +func buildBedrockOptionalFieldMask(fields *BedrockOptionalFields) []string { + if fields == nil { + return nil + } + + var fieldPaths []string + + if fields.SessionToken != nil { + fieldPaths = append(fieldPaths, "awsSessionToken") + } + if fields.Region != nil { + fieldPaths = append(fieldPaths, "awsRegion") + } + if fields.UseCrossRegionInference != nil { + fieldPaths = append(fieldPaths, "awsUseCrossRegionInference") + } + if fields.UseGlobalInference != nil { + fieldPaths = append(fieldPaths, "awsUseGlobalInference") + } + if fields.UsePromptCache != nil { + fieldPaths = append(fieldPaths, "awsBedrockUsePromptCache") + } + if fields.Authentication != nil { + fieldPaths = append(fieldPaths, "awsAuthentication") + } + if fields.UseProfile != nil { + fieldPaths = append(fieldPaths, "awsUseProfile") + } + if fields.Profile != nil { + fieldPaths = append(fieldPaths, "awsProfile") + } + if fields.Endpoint != nil { + fieldPaths = append(fieldPaths, "awsBedrockEndpoint") + } + + return fieldPaths +} + +// buildOcaOptionalFieldMask builds field mask paths for Bedrock optional fields that have values +func buildOcaOptionalFieldMask(fields *OcaOptionalFields) []string { + if fields == nil { + return nil + } + + var fieldPaths []string + + if fields.Mode != nil { + fieldPaths = append(fieldPaths, "ocaMode") + } + if fields.BaseURL != nil { + fieldPaths = append(fieldPaths, "ocaBaseUrl") + } + + return fieldPaths +} diff --git a/cli/pkg/cli/auth/wizard_byo.go b/cli/pkg/cli/auth/wizard_byo.go new file mode 100644 index 00000000000..e5fd38aa610 --- /dev/null +++ b/cli/pkg/cli/auth/wizard_byo.go @@ -0,0 +1,764 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" +) + +// ProviderWizard handles the interactive provider configuration process +type ProviderWizard struct { + ctx context.Context + manager *task.Manager +} + +// NewProviderWizard prepares a new provider configuration wizard +func NewProviderWizard(ctx context.Context) (*ProviderWizard, error) { + // Create task manager using auth instance from context + manager, err := createTaskManager(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create task manager: %w", err) + } + + return &ProviderWizard{ + ctx: ctx, + manager: manager, + }, nil +} + +// showMainMenu displays the main provider configuration menu +func (pw *ProviderWizard) showMainMenu() (string, error) { + var action string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("What would you like to do?"). + Options( + huh.NewOption("Add or change an API provider", "add"), + huh.NewOption("Change model for API provider", "change-model"), + huh.NewOption("Remove a provider", "remove"), + huh.NewOption("List configured providers", "list"), + huh.NewOption("Return to main auth menu", "back"), + ). + Value(&action), + ), + ) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("failed to get menu choice: %w", err) + } + + return action, nil +} + +// Run runs the provider configuration wizard +func (pw *ProviderWizard) Run() error { + + for { + action, err := pw.showMainMenu() + if err != nil { + return err + } + + switch action { + case "add": + if err := pw.handleAddProvider(); err != nil { + return err + } + case "change-model": + if err := pw.handleChangeModel(); err != nil { + return err + } + case "remove": + if err := pw.handleRemoveProvider(); err != nil { + return err + } + case "list": + if err := pw.handleListProviders(); err != nil { + return err + } + case "back": + // Return to main auth menu + return HandleAuthMenuNoArgs(pw.ctx) + } + fmt.Println() + } +} + +// "Add a new provider" > handleAddProvider +func (pw *ProviderWizard) handleAddProvider() error { + // Step 1: Select provider + provider, err := SelectBYOProvider() + if err != nil { + if strings.Contains(err.Error(), "cancelled") { + return nil + } + return fmt.Errorf("provider selection failed: %w", err) + } + + // Step 2: Special handling for Bedrock provider + if provider == cline.ApiProvider_BEDROCK { + return pw.handleAddBedrockProvider() + } + + // Step 2b: Special handling for OCA provider + if provider == cline.ApiProvider_OCA { + return pw.handleAddOcaProvider() + } + + // Step 3: Get API key first (for non-Bedrock providers) + apiKey, baseURL, err := PromptForAPIKey(provider) + if err != nil { + return fmt.Errorf("failed to get API key: %w", err) + } + + // Step 4: Try to fetch models and let user select (with fallback to manual entry for providers that don't support fetch) + modelID, modelInfo, err := pw.selectModel(provider, apiKey) + if err != nil { + return fmt.Errorf("model selection failed: %w", err) + } + + // Step 5: Apply configuration using AddProviderPartial + if err := AddProviderPartial(pw.ctx, pw.manager, provider, modelID, apiKey, baseURL, modelInfo); err != nil { + return fmt.Errorf("failed to save configuration: %w", err) + } + + if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil { + verboseLog("Warning: Failed to mark welcome view as completed: %v", err) + } + + fmt.Println("✓ Provider configured successfully!") + return nil +} + +// handleAddBedrockProvider handles the special case of adding Bedrock provider with its multi-field form +func (pw *ProviderWizard) handleAddBedrockProvider() error { + // Step 1: Get Bedrock configuration (all credentials and optional fields) + config, err := PromptForBedrockConfig(pw.ctx, pw.manager) + if err != nil { + if strings.Contains(err.Error(), "user declined profile authentication") { + return nil + } + return fmt.Errorf("failed to get Bedrock configuration: %w", err) + } + + // Step 2: Select model + modelID, modelInfo, err := pw.selectModel(cline.ApiProvider_BEDROCK, "") + if err != nil { + return fmt.Errorf("model selection failed: %w", err) + } + + // Step 3: Apply Bedrock configuration + if err := ApplyBedrockConfig(pw.ctx, pw.manager, config, modelID, modelInfo); err != nil { + return fmt.Errorf("failed to save Bedrock configuration: %w", err) + } + + if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil { + verboseLog("Warning: Failed to mark welcome view as completed: %v", err) + } + + fmt.Println("✓ Bedrock provider configured successfully!") + return nil +} + +// handleAddOcaProvider handles adding Oracle Code Assist provider with optional settings and auth +func (pw *ProviderWizard) handleAddOcaProvider() error { + // Step 1: Get OCA configuration (base URL and mode) + config, err := PromptForOcaConfig(pw.ctx, pw.manager) + if err != nil { + if strings.Contains(err.Error(), "user aborted") || strings.Contains(err.Error(), "cancelled") { + return nil + } + return fmt.Errorf("failed to get OCA configuration: %w", err) + } + + // Apply OCA configuration (base URL and mode) + if err := ApplyOcaConfig(pw.ctx, pw.manager, config); err != nil { + return fmt.Errorf("failed to save OCA configuration: %w", err) + } + + // Step 2: Ensure OCA authentication + if err := ensureOcaAuthenticated(pw.ctx); err != nil { + return fmt.Errorf("failed to authenticate with OCA: %w", err) + } + + // Step 3: Select model + modelID, _, err := pw.selectModel(cline.ApiProvider_OCA, "") + if err != nil { + return fmt.Errorf("model selection failed: %w", err) + } + + // Step 4: Apply the OCA model configuration and set as active + updates := ProviderUpdatesPartial{ + ModelID: &modelID, + ModelInfo: nil, + } + + if err := UpdateProviderPartial(pw.ctx, pw.manager, cline.ApiProvider_OCA, updates, true); err != nil { + return fmt.Errorf("failed to save OCA configuration: %w", err) + } + + if err := setWelcomeViewCompleted(pw.ctx, pw.manager); err != nil { + verboseLog("Warning: Failed to mark welcome view as completed: %v", err) + } + + fmt.Println("✓ OCA provider configured successfully!") + return nil +} + +// handleListProviders retrieves and displays configured providers +func (pw *ProviderWizard) handleListProviders() error { + result, err := GetProviderConfigurations(pw.ctx, pw.manager) + if err != nil { + return fmt.Errorf("failed to retrieve provider configurations: %w", err) + } + + output := FormatProviderList(result) + fmt.Println(output) + + return nil +} + +// selectModel attempts to fetch available models and let user select, or falls back to manual entry +func (pw *ProviderWizard) selectModel(provider cline.ApiProvider, apiKey string) (string, interface{}, error) { + // For providers that support model fetching, try to fetch and display models + canFetchModels := pw.supportsModelFetching(provider) + + if canFetchModels { + fmt.Println("Fetching available models...") + models, modelInfoMap, err := pw.fetchModelsForProvider(provider, apiKey) + + if err != nil { + fmt.Println("\n⚠ Unable to fetch model list from the provider. Please enter the model ID manually instead.") + if global.Config.Verbose { + fmt.Printf(" Error details: %v\n", err) + } + return pw.manualModelEntry(provider) + } + + if len(models) == 0 { + fmt.Println("\n⚠ No models found from the provider. Please enter the model ID manually instead.") + return pw.manualModelEntry(provider) + } + + // Let user select from available models (includes manual entry option) + modelID, err := pw.selectFromAvailableModels(models) + if err != nil { + return "", nil, fmt.Errorf("model selection failed: %w", err) + } + + // Check if user chose manual entry + const manualEntryKey = "__MANUAL_ENTRY__" + if modelID == manualEntryKey { + return pw.manualModelEntry(provider) + } + + // Get the model info for the selected model + var modelInfo interface{} + if modelInfoMap != nil { + modelInfo = modelInfoMap[modelID] + } + + return modelID, modelInfo, nil + } + + // For providers without model fetching support, use manual entry + return pw.manualModelEntry(provider) +} + +// supportsModelFetching returns true if the provider supports fetching models +func (pw *ProviderWizard) supportsModelFetching(provider cline.ApiProvider) bool { + return SupportsBYOModelFetching(provider) +} + +// fetchModelsForProvider fetches models for a given provider +// Supports both dynamic API fetching (OpenRouter, OpenAI, Ollama) and static model lists (Anthropic, Bedrock, Gemini, X AI) +func (pw *ProviderWizard) fetchModelsForProvider(provider cline.ApiProvider, apiKey string) ([]string, map[string]interface{}, error) { + // Try dynamic/remote model fetching first + switch provider { + case cline.ApiProvider_OPENROUTER: + models, err := FetchOpenRouterModels(pw.ctx, pw.manager) + if err != nil { + return nil, nil, err + } + interfaceMap := ConvertOpenRouterModelsToInterface(models) + return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil + + case cline.ApiProvider_OPENAI: + // For OpenAI, we need to pass the base URL and API key + baseURL := "https://api.openai.com/v1" // Default OpenAI API base URL + modelIDs, err := FetchOpenAiModels(pw.ctx, pw.manager, baseURL, apiKey) + if err != nil { + return nil, nil, err + } + // OpenAI returns just model IDs without additional info, so modelInfo map is nil + return modelIDs, nil, nil + + case cline.ApiProvider_OLLAMA: + // For Ollama, apiKey actually contains the base URL (or empty for default) + baseURL := apiKey // The "API key" field for Ollama is actually the base URL + modelIDs, err := FetchOllamaModels(pw.ctx, pw.manager, baseURL) + if err != nil { + return nil, nil, err + } + // Ollama returns just model IDs without additional info, so modelInfo map is nil + return modelIDs, nil, nil + + case cline.ApiProvider_OCA: + // OCA supports dynamic model fetching + models, err := FetchOcaModels(pw.ctx, pw.manager) + if err != nil { + return nil, nil, err + } + interfaceMap := ConvertOcaModelsToInterface(models) + return ConvertModelsMapToSlice(interfaceMap), interfaceMap, nil + } + + // Fall back to static models for providers that don't support dynamic fetching + if SupportsStaticModelList(provider) { + modelIDs, _, err := FetchStaticModels(provider) + if err != nil { + return nil, nil, err + } + // Static models don't have detailed info maps for now, so modelInfo map is nil + return modelIDs, nil, nil + } + + return nil, nil, fmt.Errorf("model fetching not supported for provider: %v", provider) +} + +// selectFromAvailableModels displays available models and lets user select one. +// Includes an option to enter a model ID manually in case the desired model isn't listed. +func (pw *ProviderWizard) selectFromAvailableModels(models []string) (string, error) { + if len(models) == 0 { + return "", fmt.Errorf("no models available") + } + + // Add a special "manual entry" option at the end + const manualEntryKey = "__MANUAL_ENTRY__" + + // Use model ID as the value (not index) + var selectedModel string + options := make([]huh.Option[string], len(models)+1) + for i, model := range models { + options[i] = huh.NewOption(model, model) + } + // Add manual entry option at the end + options[len(models)] = huh.NewOption("Enter model ID manually...", manualEntryKey) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a model"). + Options(options...). + Height(calculateSelectHeight()). + Filtering(true). + Value(&selectedModel), + ), + ) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("failed to select model: %w", err) + } + + // If user selected manual entry, return special key to trigger manual input + if selectedModel == manualEntryKey { + return manualEntryKey, nil + } + + return selectedModel, nil +} + +// manualModelEntry prompts user to manually enter a model ID. +// Returns the model ID and an error. The modelInfo is always nil for manual entry. +func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string, interface{}, error) { + var modelID string + modelPlaceholder := GetBYOProviderPlaceholder(provider) + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Model ID"). + Placeholder(modelPlaceholder). + Value(&modelID). + Validate(func(s string) error { + // Trim whitespace and validate + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return fmt.Errorf("model ID cannot be empty") + } + return nil + }), + ), + ) + + if err := form.Run(); err != nil { + return "", nil, fmt.Errorf("failed to get model ID: %w", err) + } + + // Trim whitespace from the final value + modelID = strings.TrimSpace(modelID) + + // modelInfo is always nil for manual entry + return modelID, nil, nil +} + +// handleChangeModel allows changing the model for any configured provider +func (pw *ProviderWizard) handleChangeModel() error { + // Step 1: Get current provider configurations + result, err := GetProviderConfigurations(pw.ctx, pw.manager) + if err != nil { + return fmt.Errorf("failed to retrieve provider configurations: %w", err) + } + + // Step 2: Get all configured providers with models + readyProviders := result.GetAllReadyProviders() + + // Filter out Cline provider (it has its own model changer in the main menu) + var configurableProviders []*ProviderDisplay + for _, provider := range readyProviders { + if provider.Provider != cline.ApiProvider_CLINE { + configurableProviders = append(configurableProviders, provider) + } + } + + // Step 3: Check if there are any configurable providers + if len(configurableProviders) == 0 { + fmt.Println("\nNo configurable providers found.") + fmt.Println("Note: Cline provider has its own model selection in the main menu.") + return nil + } + + // Step 4: Let user select which provider to change the model for + var selectedIndex int + options := make([]huh.Option[int], len(configurableProviders)+1) + for i, providerDisplay := range configurableProviders { + displayName := fmt.Sprintf("%s (current: %s)", + GetProviderDisplayName(providerDisplay.Provider), + providerDisplay.ModelID) + options[i] = huh.NewOption(displayName, i) + } + options[len(configurableProviders)] = huh.NewOption("(Cancel)", -1) + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Select provider to change model for"). + Options(options...). + Value(&selectedIndex), + ), + ) + + if err := form.Run(); err != nil { + return fmt.Errorf("failed to select provider: %w", err) + } + + if selectedIndex == -1 { + return nil + } + + selectedProvider := configurableProviders[selectedIndex] + provider := selectedProvider.Provider + + fmt.Printf("\nChanging model for %s\n", GetProviderDisplayName(provider)) + fmt.Printf("Current model: %s\n\n", selectedProvider.ModelID) + + // Step 5: Retrieve API key if needed for model fetching + var apiKey string + if pw.supportsModelFetching(provider) { + // For providers that support fetching, we need to retrieve the API key from state + state, err := pw.manager.GetClient().State.GetLatestState(pw.ctx, &cline.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get state: %w", err) + } + + var stateData map[string]interface{} + if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil { + return fmt.Errorf("failed to parse state JSON: %w", err) + } + + apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{}) + if !ok { + return fmt.Errorf("no API configuration found in state") + } + + apiKey = getProviderAPIKeyFromState(apiConfig, provider) + if apiKey == "" { + return fmt.Errorf("no API key found for provider %s", GetProviderDisplayName(provider)) + } + } + + modelID, modelInfo, err := pw.selectModel(provider, apiKey) + if err != nil { + return fmt.Errorf("model selection failed: %w", err) + } + + // Step 6: Apply the model change (for both Plan and Act modes) + if err := pw.applyModelChange(provider, modelID, modelInfo); err != nil { + return fmt.Errorf("failed to apply model change: %w", err) + } + + fmt.Printf("✓ Model changed successfully to: %s\n", modelID) + fmt.Println(" (Applied to both Plan and Act modes)") + return nil +} + +// applyModelChange applies a model change for both Plan and Act modes using UpdateProviderPartial +func (pw *ProviderWizard) applyModelChange(provider cline.ApiProvider, modelID string, modelInfo interface{}) error { + updates := ProviderUpdatesPartial{ + ModelID: &modelID, + ModelInfo: modelInfo, + } + + return UpdateProviderPartial(pw.ctx, pw.manager, provider, updates, false) +} + +// SwitchToBYOProvider switches to a BYO provider that's already configured. +// It retrieves the existing model configuration and sets it as the active provider for both Plan and Act modes. +func SwitchToBYOProvider(ctx context.Context, manager *task.Manager, provider cline.ApiProvider) error { + // Get the current state to retrieve the model ID and model info for this provider + state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get state: %w", err) + } + + // Parse state JSON + var stateData map[string]interface{} + if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil { + return fmt.Errorf("failed to parse state JSON: %w", err) + } + + // Extract apiConfiguration + apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{}) + if !ok { + return fmt.Errorf("no API configuration found in state") + } + + // Get the model ID for the selected provider + modelID := getProviderModelIDFromState(apiConfig, provider) + if modelID == "" { + return fmt.Errorf("no model configured for provider %s", GetProviderDisplayName(provider)) + } + + // Get model info if available (for OpenRouter/Cline) + var modelInfo interface{} + if provider == cline.ApiProvider_OPENROUTER || provider == cline.ApiProvider_CLINE { + if modelInfoData, ok := apiConfig["planModeOpenRouterModelInfo"].(map[string]interface{}); ok { + modelInfo = convertMapToOpenRouterModelInfo(modelInfoData) + } + } + + // Use UpdateProviderPartial to switch to this provider + updates := ProviderUpdatesPartial{ + ModelID: &modelID, + ModelInfo: modelInfo, + } + + if err := UpdateProviderPartial(ctx, manager, provider, updates, true); err != nil { + return fmt.Errorf("failed to switch provider: %w", err) + } + + verboseLog("✓ Switched to %s\n", GetProviderDisplayName(provider)) + verboseLog(" Using model: %s\n", modelID) + + return HandleAuthMenuNoArgs(ctx) +} + +// getProviderModelIDFromState retrieves the model ID for a specific provider from state +func getProviderModelIDFromState(stateData map[string]interface{}, provider cline.ApiProvider) string { + modelKey, err := GetModelIDFieldName(provider, "plan") + if err != nil { + return "" + } + + if modelID, ok := stateData[modelKey].(string); ok { + return modelID + } + + return "" +} + + // getProviderAPIKeyFromState retrieves the API key for a specific provider from state +func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string { + // OCA uses account authentication, not API keys. Consider it "present" if authenticated. + if provider == cline.ApiProvider_OCA { + if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil { + // Return a sentinel non-empty string so upstream checks pass. + return "OCA_AUTH_VERIFIED" + } + return "" + } + + fields, err := GetProviderFields(provider) + if err != nil { + return "" + } + + if apiKey, ok := stateData[fields.APIKeyField].(string); ok { + return apiKey + } + + return "" +} + +// convertMapToOpenRouterModelInfo converts a map to OpenRouterModelInfo +func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRouterModelInfo { + info := &cline.OpenRouterModelInfo{} + + if val, ok := data["description"].(string); ok { + info.Description = &val + } + if val, ok := data["contextWindow"].(float64); ok { + contextWindow := int64(val) + info.ContextWindow = &contextWindow + } + if val, ok := data["maxTokens"].(float64); ok { + maxTokens := int64(val) + info.MaxTokens = &maxTokens + } + if val, ok := data["inputPrice"].(float64); ok { + info.InputPrice = &val + } + if val, ok := data["outputPrice"].(float64); ok { + info.OutputPrice = &val + } + if val, ok := data["cacheWritesPrice"].(float64); ok { + info.CacheWritesPrice = &val + } + if val, ok := data["cacheReadsPrice"].(float64); ok { + info.CacheReadsPrice = &val + } + if val, ok := data["supportsImages"].(bool); ok { + info.SupportsImages = &val + } + if val, ok := data["supportsPromptCache"].(bool); ok { + info.SupportsPromptCache = val + } + + return info +} + +// handleRemoveProvider allows removing a configured provider by clearing its API key +func (pw *ProviderWizard) handleRemoveProvider() error { + // Step 1: Get current provider configurations + result, err := GetProviderConfigurations(pw.ctx, pw.manager) + if err != nil { + return fmt.Errorf("failed to retrieve provider configurations: %w", err) + } + + // Step 2: Get all ready providers + readyProviders := result.GetAllReadyProviders() + + // Filter out Cline provider (uses account auth, not API keys) + var removableProviders []*ProviderDisplay + for _, provider := range readyProviders { + if provider.Provider != cline.ApiProvider_CLINE { + removableProviders = append(removableProviders, provider) + } + } + + // Step 3: Check if there are providers to remove + if len(removableProviders) == 0 { + fmt.Println("\nNo providers available to remove.") + fmt.Println("Note: Cline provider cannot be removed via this menu.") + return nil + } + + // Step 4: Display selection menu + var selectedIndex int + options := make([]huh.Option[int], len(removableProviders)) + for i, provider := range removableProviders { + // Mark active provider + displayName := GetProviderDisplayName(provider.Provider) + if result.ActProvider != nil && provider.Provider == result.ActProvider.Provider { + displayName += " (ACTIVE)" + } + options[i] = huh.NewOption(displayName, i) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[int](). + Title("Select provider to remove"). + Options(options...). + Value(&selectedIndex), + ), + ) + + if err := form.Run(); err != nil { + return fmt.Errorf("failed to select provider: %w", err) + } + + selectedProvider := removableProviders[selectedIndex] + + // Step 5: Check if trying to remove the active provider + if result.ActProvider != nil && selectedProvider.Provider == result.ActProvider.Provider { + fmt.Printf("\nCannot remove %s because it is currently active.\n", GetProviderDisplayName(selectedProvider.Provider)) + fmt.Println("Please switch to a different provider first, then try again.") + return nil + } + + // Step 6: Confirm removal + var confirm bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Are you sure you want to remove %s?", GetProviderDisplayName(selectedProvider.Provider))). + Description("This will clear the API key but preserve the model configuration."). + Value(&confirm), + ), + ) + + if err := confirmForm.Run(); err != nil { + return fmt.Errorf("failed to get confirmation: %w", err) + } + + if !confirm { + fmt.Println("Removal cancelled.") + return nil + } + + // Step 7: If removing OCA, sign out first + if selectedProvider.Provider == cline.ApiProvider_OCA { + if err := signOutOca(pw.ctx); err != nil { + fmt.Printf("Warning: Failed to sign out of OCA: %v\n", err) + } else { + fmt.Println("Signed out of OCA.") + } + } + + // Step 8: Clear the API key for the selected provider + if err := pw.clearProviderAPIKey(selectedProvider.Provider); err != nil { + return fmt.Errorf("failed to remove provider: %w", err) + } + + fmt.Printf("\n✓ %s removed successfully\n", GetProviderDisplayName(selectedProvider.Provider)) + return nil +} + +// clearProviderAPIKey clears the API key field for a specific provider using RemoveProviderPartial +func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error { + return RemoveProviderPartial(pw.ctx, pw.manager, provider) +} + + +func signOutOca(ctx context.Context) error { + client, err := global.GetDefaultClient(ctx) + if err != nil { + return err + } + _, err = client.Ocaaccount.OcaAccountLogoutClicked(ctx, &cline.EmptyRequest{}) + return err +} + +func setWelcomeViewCompleted(ctx context.Context, manager *task.Manager) error { + _, err := manager.GetClient().State.SetWelcomeViewCompleted(ctx, &cline.BooleanRequest{Value: true}) + return err +} diff --git a/cli/pkg/cli/auth/wizard_byo_bedrock.go b/cli/pkg/cli/auth/wizard_byo_bedrock.go new file mode 100644 index 00000000000..38ae9f0fece --- /dev/null +++ b/cli/pkg/cli/auth/wizard_byo_bedrock.go @@ -0,0 +1,193 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +// BedrockConfig holds all AWS Bedrock-specific configuration fields +type BedrockConfig struct { + // Profile authentication fields + UseProfile bool // Always true for successful config + Profile string // Optional: AWS profile name (empty = default) + Region string // Required: AWS region + Endpoint string // Optional: Custom VPC endpoint URL + + // Optional features + UseCrossRegionInference bool // Optional: Enable cross-region inference + UseGlobalInference bool // Optional: Use global inference endpoint + UsePromptCache bool // Optional: Enable prompt caching + + // Authentication method (always "profile") + Authentication string // Always set to "profile" + + // Legacy fields (no longer used in profile-only flow) + AccessKey string // No longer used + SecretKey string // No longer used + SessionToken string // No longer used +} + +// PromptForBedrockConfig displays a profile-first authentication form for Bedrock configuration +func PromptForBedrockConfig(ctx context.Context, manager *task.Manager) (*BedrockConfig, error) { + config := &BedrockConfig{} + + // First, ask if user wants to use AWS profile authentication + var useProfile bool + profileQuestion := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Do you want to use an AWS profile for authentication?"). + Description("AWS profiles are managed via 'aws configure'"). + Value(&useProfile). + Affirmative("Yes"). + Negative("No"). + Inline(true), + ), + ) + + if err := profileQuestion.Run(); err != nil { + return nil, fmt.Errorf("failed to get authentication method: %w", err) + } + + // If user declines profile authentication, show message and return error + if !useProfile { + fmt.Println("\nAWS profile authentication is currently the only supported method in the CLI.") + fmt.Println("Please configure an AWS profile using 'aws configure' and try again.") + return nil, fmt.Errorf("user declined profile authentication") + } + + // User wants profile auth - collect profile configuration + config.UseProfile = true + config.Authentication = "profile" + + // Collect profile name, region, and optional settings + configForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("AWS Profile Name (optional, press Enter for default profile)"). + Value(&config.Profile). + Description("Leave empty to use default AWS profile"), + + huh.NewInput(). + Title("AWS Region (required, e.g., us-east-1)"). + Value(&config.Region). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("AWS Region is required") + } + return nil + }), + + huh.NewInput(). + Title("Custom VPC Endpoint URL (optional)"). + Value(&config.Endpoint). + Description("Press Enter to skip"), + + huh.NewConfirm(). + Title("Enable Prompt Cache? "). + Value(&config.UsePromptCache). + Affirmative("Yes"). + Negative("No"). + Inline(true), + + huh.NewConfirm(). + Title("Enable Cross-Region Inference? "). + Value(&config.UseCrossRegionInference). + Affirmative("Yes"). + Negative("No"). + Inline(true), + + huh.NewConfirm(). + Title("Use Global Inference Endpoint? "). + Value(&config.UseGlobalInference). + Affirmative("Yes"). + Negative("No"). + Inline(true), + ), + ) + + if err := configForm.Run(); err != nil { + return nil, fmt.Errorf("failed to get Bedrock configuration: %w", err) + } + + // Trim whitespace from string fields + config.Profile = strings.TrimSpace(config.Profile) + config.Region = strings.TrimSpace(config.Region) + config.Endpoint = strings.TrimSpace(config.Endpoint) + + return config, nil +} + +// ApplyBedrockConfig applies Bedrock configuration using partial updates (profile-only) +func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *BedrockConfig, modelID string, modelInfo interface{}) error { + // Build the API configuration with all Bedrock fields + apiConfig := &cline.ModelsApiConfiguration{} + + // Set model ID fields + apiConfig.PlanModeApiModelId = proto.String(modelID) + apiConfig.ActModeApiModelId = proto.String(modelID) + apiConfig.PlanModeAwsBedrockCustomModelBaseId = proto.String(modelID) + apiConfig.ActModeAwsBedrockCustomModelBaseId = proto.String(modelID) + + // Set profile authentication fields (always required) + optionalFields := &BedrockOptionalFields{} + optionalFields.Authentication = proto.String("profile") + optionalFields.UseProfile = proto.Bool(true) + optionalFields.Region = proto.String(config.Region) + + // Set profile name (can be empty for default profile) + if config.Profile != "" { + optionalFields.Profile = proto.String(config.Profile) + } + + // Set optional fields if provided + if config.Endpoint != "" { + optionalFields.Endpoint = proto.String(config.Endpoint) + } + if config.UseCrossRegionInference { + optionalFields.UseCrossRegionInference = proto.Bool(true) + } + if config.UseGlobalInference { + optionalFields.UseGlobalInference = proto.Bool(true) + } + if config.UsePromptCache { + optionalFields.UsePromptCache = proto.Bool(true) + } + + // Apply all fields to the config + setBedrockOptionalFields(apiConfig, optionalFields) + + // Build field mask including all fields we're setting (excluding access keys) + fieldPaths := []string{ + "planModeApiModelId", + "actModeApiModelId", + "planModeAwsBedrockCustomModelBaseId", + "actModeAwsBedrockCustomModelBaseId", + } + + // Add profile authentication field paths + optionalPaths := buildBedrockOptionalFieldMask(optionalFields) + fieldPaths = append(fieldPaths, optionalPaths...) + + // Create field mask + fieldMask := &fieldmaskpb.FieldMask{Paths: fieldPaths} + + // Apply the partial update + request := &cline.UpdateApiConfigurationPartialRequest{ + ApiConfiguration: apiConfig, + UpdateMask: fieldMask, + } + + if err := updateApiConfigurationPartial(ctx, manager, request); err != nil { + return fmt.Errorf("failed to apply Bedrock configuration: %w", err) + } + + return nil +} diff --git a/cli/pkg/cli/auth/wizard_byo_oca.go b/cli/pkg/cli/auth/wizard_byo_oca.go new file mode 100644 index 00000000000..7ec37150c7b --- /dev/null +++ b/cli/pkg/cli/auth/wizard_byo_oca.go @@ -0,0 +1,366 @@ +package auth + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/charmbracelet/huh" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/cline/grpc-go/cline" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +// OcaConfig holds Oracle Code Assist (OCA) configuration fields +type OcaConfig struct { + BaseURL string + Mode string +} + +// PromptForOcaConfig displays a form for OCA configuration (base URL and mode) +func PromptForOcaConfig(ctx context.Context, manager *task.Manager) (*OcaConfig, error) { + config := &OcaConfig{} + var mode string + + // Collect optional settings + configForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Base URL"). + Value(&config.BaseURL). + Description("Leave empty to use default Base URL"), + + huh.NewSelect[string](). + Title("Choose OCA mode (used for authentication)"). + Description("Select 'Internal' to use Cline's internal OCA, or 'External' for your own OCA instance"). + Options( + huh.NewOption("Internal", "internal"), + huh.NewOption("External", "external"), + ). + Value(&mode), + ), + ) + + if err := configForm.Run(); err != nil { + return nil, fmt.Errorf("failed to get OCA configuration: %w", err) + } + + // Trim whitespace from string fields + config.BaseURL = strings.TrimSpace(config.BaseURL) + config.Mode = strings.TrimSpace(mode) + + return config, nil +} + +// ApplyOcaConfig applies OCA configuration using partial updates +func ApplyOcaConfig(ctx context.Context, manager *task.Manager, config *OcaConfig) error { + // Build the API configuration with all OCA fields + apiConfig := &cline.ModelsApiConfiguration{} + + // Set profile authentication fields (always required) + optionalFields := &OcaOptionalFields{} + + // Set profile name (can be empty for default profile) + if config.BaseURL != "" { + optionalFields.BaseURL = proto.String(config.BaseURL) + } + + // Set optional fields if provided + if config.Mode != "" { + optionalFields.Mode = proto.String(config.Mode) + } + + // Apply all fields to the config + setOcaOptionalFields(apiConfig, optionalFields) + + // Add profile authentication field paths + optionalPaths := buildOcaOptionalFieldMask(optionalFields) + + // Create field mask + fieldMask := &fieldmaskpb.FieldMask{Paths: optionalPaths} + + // Apply the partial update + request := &cline.UpdateApiConfigurationPartialRequest{ + ApiConfiguration: apiConfig, + UpdateMask: fieldMask, + } + + if err := updateApiConfigurationPartial(ctx, manager, request); err != nil { + return fmt.Errorf("failed to apply OCA configuration: %w", err) + } + + return nil +} + +// =========================== +// OCA Auth Listener Singleton +// =========================== + +type ocaAuthStream interface { + Recv() (*cline.OcaAuthState, error) +} + +// OcaAuthStatusListener manages subscription to OCA auth status updates +type OcaAuthStatusListener struct { + stream ocaAuthStream + updatesCh chan *cline.OcaAuthState + errCh chan error + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + lastState *cline.OcaAuthState + firstEventCh chan struct{} + firstEventOnce sync.Once +} + +// NewOcaAuthStatusListener creates a new OCA auth status listener +func NewOcaAuthStatusListener(parentCtx context.Context) (*OcaAuthStatusListener, error) { + client, err := global.GetDefaultClient(parentCtx) + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + // Keep the listener alive independently of short-lived caller contexts + ctx, cancel := context.WithCancel(context.Background()) + + // Subscribe to OCA auth status updates + stream, err := client.Ocaaccount.OcaSubscribeToAuthStatusUpdate(ctx, &cline.EmptyRequest{}) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to subscribe to OCA auth updates: %w", err) + } + + return &OcaAuthStatusListener{ + stream: stream, + updatesCh: make(chan *cline.OcaAuthState, 10), + errCh: make(chan error, 1), + ctx: ctx, + cancel: cancel, + firstEventCh: make(chan struct{}), + }, nil +} + +// Start begins listening to the auth status update stream +func (l *OcaAuthStatusListener) Start() error { + go l.readStream() + return nil +} + +func (l *OcaAuthStatusListener) readStream() { + defer close(l.updatesCh) + defer close(l.errCh) + + for { + select { + case <-l.ctx.Done(): + return + default: + state, err := l.stream.Recv() + if err != nil { + // Propagate error and exit + if err == io.EOF { + // Treat as error to notify waiters + err = fmt.Errorf("OCA auth status stream closed") + } + select { + case l.errCh <- err: + case <-l.ctx.Done(): + } + return + } + + l.mu.Lock() + l.lastState = state + l.mu.Unlock() + + // Notify first event waiters + l.firstEventOnce.Do(func() { close(l.firstEventCh) }) + + select { + case l.updatesCh <- state: + case <-l.ctx.Done(): + return + } + } + } +} + +// WaitForFirstEvent blocks until the first event is received or timeout occurs +func (l *OcaAuthStatusListener) WaitForFirstEvent(timeout time.Duration) error { + // Fast-path if already have a state + l.mu.RLock() + ready := l.lastState != nil + l.mu.RUnlock() + if ready { + return nil + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case <-l.firstEventCh: + return nil + case <-timer.C: + return fmt.Errorf("timeout waiting for initial OCA auth event") + case <-l.ctx.Done(): + return fmt.Errorf("OCA auth listener cancelled") + } +} + +// IsAuthenticated returns true if the last known OCA auth state is authenticated +func (l *OcaAuthStatusListener) IsAuthenticated() bool { + l.mu.RLock() + defer l.mu.RUnlock() + return isOCAStateAuthenticated(l.lastState) +} + +// WaitForAuthentication waits until OCA authentication succeeds or timeout occurs +func (l *OcaAuthStatusListener) WaitForAuthentication(timeout time.Duration) error { + timer := time.NewTimer(timeout) + defer timer.Stop() + + // If already authenticated, return immediately + if l.IsAuthenticated() { + return nil + } + + for { + select { + case <-timer.C: + return fmt.Errorf("OCA authentication timeout after %v - please try again", timeout) + case <-l.ctx.Done(): + return fmt.Errorf("OCA authentication cancelled") + case err := <-l.errCh: + return fmt.Errorf("OCA authentication stream error: %w", err) + case state := <-l.updatesCh: + if isOCAStateAuthenticated(state) { + return nil + } + } + } +} + +// Stop closes the stream and cleans up resources +func (l *OcaAuthStatusListener) Stop() { + l.cancel() +} + +func isOCAStateAuthenticated(state *cline.OcaAuthState) bool { + return state != nil && state.User != nil +} + +// Singleton holder +var ( + ocaListener *OcaAuthStatusListener + ocaListenerOnce sync.Once + ocaListenerErr error +) + +// GetOcaAuthListener returns the OCA auth listener singleton +func GetOcaAuthListener(ctx context.Context) (*OcaAuthStatusListener, error) { + // Allow optional ctx: if nil, use context.TODO(). If already initialized, return singleton. + if ctx == nil { + ctx = context.TODO() + } + + ocaListenerOnce.Do(func() { + l, err := NewOcaAuthStatusListener(ctx) + if err != nil { + ocaListenerErr = err + return + } + if err := l.Start(); err != nil { + ocaListenerErr = err + return + } + ocaListener = l + }) + return ocaListener, ocaListenerErr +} + +// IsOCAAuthenticated returns true if the global OCA auth status is authenticated. +// It attempts a brief wait for the first event to avoid stale reads. +func IsOCAAuthenticated(ctx context.Context) bool { + l, err := GetOcaAuthListener(ctx) + if err != nil { + return false + } + _ = l.WaitForFirstEvent(1 * time.Second) // best-effort + return l.IsAuthenticated() +} + + // LatestState returns the last received OCA auth state (may be nil) +func (l *OcaAuthStatusListener) LatestState() *cline.OcaAuthState { + l.mu.RLock() + defer l.mu.RUnlock() + return l.lastState +} + +// GetLatestOCAState returns the latest known OCA auth state, optionally waiting for the first event +func GetLatestOCAState(ctx context.Context, timeout time.Duration) (*cline.OcaAuthState, error) { + l, err := GetOcaAuthListener(ctx) + if err != nil { + return nil, err + } + if timeout > 0 { + if err := l.WaitForFirstEvent(timeout); err != nil { + return nil, err + } + } + return l.LatestState(), nil +} + +// ensureOcaAuthenticated initiates OCA login (if needed) and waits for success using the singleton listener +func ensureOcaAuthenticated(ctx context.Context) error { + // Ensure listener exists + listener, err := GetOcaAuthListener(ctx) + if err != nil { + return fmt.Errorf("failed to initialize OCA auth listener: %w", err) + } + + // Briefly wait for first event to know current state + _ = listener.WaitForFirstEvent(1 * time.Second) + + // If already authenticated, nothing to do + if listener.IsAuthenticated() { + fmt.Println("✓ OCA authentication already active.") + return nil + } + + // Create gRPC client for initiating login + client, err := global.GetDefaultClient(ctx) + if err != nil { + return fmt.Errorf("failed to obtain client: %w", err) + } + + // Start login and wait for authentication + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + // Initiate login (opens the browser with a callback URL from Cline Core) + response, err := client.Ocaaccount.OcaAccountLoginClicked(waitCtx, &cline.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to initiate OCA login: %w", err) + } + + fmt.Println("\nOpening browser for OCA authentication...") + if response != nil && response.Value != "" { + fmt.Printf("If the browser doesn't open automatically, visit this URL:\n%s\n\n", response.Value) + } + fmt.Println("Waiting for you to complete OCA authentication in your browser...") + fmt.Println("(This may take a few moments. Timeout: 5 minutes)") + + // Block until authenticated or timeout + if err := listener.WaitForAuthentication(5 * time.Minute); err != nil { + return err + } + + fmt.Println("✓ OCA authentication successful!") + return nil +} diff --git a/cli/pkg/cli/clerror/cline_error.go b/cli/pkg/cli/clerror/cline_error.go new file mode 100644 index 00000000000..dc177766a0f --- /dev/null +++ b/cli/pkg/cli/clerror/cline_error.go @@ -0,0 +1,187 @@ +package clerror + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ClineErrorType represents the category of error +type ClineErrorType string + +const ( + ErrorTypeAuth ClineErrorType = "auth" + ErrorTypeNetwork ClineErrorType = "network" + ErrorTypeRateLimit ClineErrorType = "rateLimit" + ErrorTypeBalance ClineErrorType = "balance" + ErrorTypeUnknown ClineErrorType = "unknown" +) + +// ClineError represents a parsed error from Cline API +type ClineError struct { + Message string `json:"message"` + Status int `json:"status"` + RequestID string `json:"request_id"` + Code interface{} `json:"code"` // Can be string or int + ModelID string `json:"modelId"` + ProviderID string `json:"providerId"` + Details map[string]interface{} `json:"details"` +} + +// GetCodeString returns the code as a string regardless of its type +func (e *ClineError) GetCodeString() string { + if e == nil || e.Code == nil { + return "" + } + switch v := e.Code.(type) { + case string: + return v + case float64: + return fmt.Sprintf("%.0f", v) + case int: + return fmt.Sprintf("%d", v) + default: + return fmt.Sprintf("%v", v) + } +} + +// Rate limit patterns from webview +var rateLimitPatterns = []string{ + "status code 429", + "rate limit", + "too many requests", + "quota exceeded", + "resource exhausted", +} + +// ParseClineError parses a JSON error string into a ClineError +func ParseClineError(errorJSON string) (*ClineError, error) { + if errorJSON == "" { + return nil, nil + } + + var err ClineError + if parseErr := json.Unmarshal([]byte(errorJSON), &err); parseErr != nil { + // If JSON parsing fails, create a simple error with the message + return &ClineError{ + Message: errorJSON, + }, nil + } + + return &err, nil +} + +// GetErrorType determines the type of error based on code, status, and message +func (e *ClineError) GetErrorType() ClineErrorType { + if e == nil { + return ErrorTypeUnknown + } + + // Check balance error first (most specific) + codeStr := e.GetCodeString() + if codeStr == "insufficient_credits" { + return ErrorTypeBalance + } + + // Check auth errors + if codeStr == "ERR_BAD_REQUEST" || e.Status == 401 { + return ErrorTypeAuth + } + + // Check for auth message + if strings.Contains(e.Message, "Authentication required") || + strings.Contains(e.Message, "Invalid API key") || + strings.Contains(e.Message, "Unauthorized") { + return ErrorTypeAuth + } + + // Check rate limit patterns + messageLower := strings.ToLower(e.Message) + for _, pattern := range rateLimitPatterns { + if strings.Contains(messageLower, pattern) { + return ErrorTypeRateLimit + } + } + + return ErrorTypeUnknown +} + +// IsBalanceError returns true if this is a balance/credits error +func (e *ClineError) IsBalanceError() bool { + return e.GetErrorType() == ErrorTypeBalance +} + +// IsAuthError returns true if this is an authentication error +func (e *ClineError) IsAuthError() bool { + return e.GetErrorType() == ErrorTypeAuth +} + +// IsRateLimitError returns true if this is a rate limit error +func (e *ClineError) IsRateLimitError() bool { + return e.GetErrorType() == ErrorTypeRateLimit +} + +// GetCurrentBalance returns the current balance if available +func (e *ClineError) GetCurrentBalance() *float64 { + if e == nil || e.Details == nil { + return nil + } + + if balance, ok := e.Details["current_balance"].(float64); ok { + return &balance + } + + return nil +} + +// GetBuyCreditsURL returns the URL to buy credits if available +func (e *ClineError) GetBuyCreditsURL() string { + if e == nil || e.Details == nil { + return "" + } + + if url, ok := e.Details["buy_credits_url"].(string); ok { + return url + } + + return "" +} + +// GetTotalSpent returns the total spent amount if available +func (e *ClineError) GetTotalSpent() *float64 { + if e == nil || e.Details == nil { + return nil + } + + if spent, ok := e.Details["total_spent"].(float64); ok { + return &spent + } + + return nil +} + +// GetTotalPromotions returns the total promotions amount if available +func (e *ClineError) GetTotalPromotions() *float64 { + if e == nil || e.Details == nil { + return nil + } + + if promos, ok := e.Details["total_promotions"].(float64); ok { + return &promos + } + + return nil +} + +// GetDetailMessage returns the detail message from error.details if available +func (e *ClineError) GetDetailMessage() string { + if e == nil || e.Details == nil { + return "" + } + + if msg, ok := e.Details["message"].(string); ok { + return msg + } + + return "" +} diff --git a/cli/pkg/cli/config.go b/cli/pkg/cli/config.go new file mode 100644 index 00000000000..9c02c580fca --- /dev/null +++ b/cli/pkg/cli/config.go @@ -0,0 +1,149 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/cline/cli/pkg/cli/config" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/task" + "github.com/spf13/cobra" +) + +var configManager *config.Manager + +func ensureConfigManager(ctx context.Context, address string) error { + if configManager == nil || (address != "" && configManager.GetCurrentInstance() != address) { + var err error + var instanceAddress string + + if address != "" { + // Ensure instance exists at the specified address + if err := ensureInstanceAtAddress(ctx, address); err != nil { + return fmt.Errorf("failed to ensure instance at address %s: %w", address, err) + } + configManager, err = config.NewManager(ctx, address) + instanceAddress = address + } else { + // Ensure default instance exists + if err := global.EnsureDefaultInstance(ctx); err != nil { + return fmt.Errorf("failed to ensure default instance: %w", err) + } + configManager, err = config.NewManager(ctx, "") + if err == nil { + instanceAddress = configManager.GetCurrentInstance() + } + } + + if err != nil { + return fmt.Errorf("failed to create config manager: %w", err) + } + + // Always set the instance we're using as the default + registry := global.Clients.GetRegistry() + if err := registry.SetDefaultInstance(instanceAddress); err != nil { + // Log warning but don't fail - this is not critical + fmt.Printf("Warning: failed to set default instance: %v\n", err) + } + } + return nil +} + +func NewConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Aliases: []string{"c"}, + Short: "Manage Cline configuration", + Long: `Set and manage global Cline configuration variables.`, + } + + cmd.AddCommand(newConfigListCommand()) + cmd.AddCommand(newConfigGetCommand()) + cmd.AddCommand(setCommand()) + + return cmd +} + +func newConfigGetCommand() *cobra.Command { + var address string + + cmd := &cobra.Command{ + Use: "get ", + 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...]", + Aliases: []string{"s"}, + Short: "Set configuration variables", + Long: `Set one or more global configuration variables using key=value format.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Parse using existing task parser + settings, secrets, err := task.ParseTaskSettings(args) + if err != nil { + return fmt.Errorf("failed to parse settings: %w", err) + } + + // Ensure config manager + if err := ensureConfigManager(ctx, address); err != nil { + return err + } + + // Update settings + return configManager.UpdateSettings(ctx, settings, secrets) + }, + } + + cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") + return cmd +} diff --git a/cli/pkg/cli/config/manager.go b/cli/pkg/cli/config/manager.go new file mode 100644 index 00000000000..1f3def9b88d --- /dev/null +++ b/cli/pkg/cli/config/manager.go @@ -0,0 +1,208 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/grpc-go/client" + "github.com/cline/grpc-go/cline" +) + +type Manager struct { + client *client.ClineClient + clientAddress string +} + +func NewManager(ctx context.Context, address string) (*Manager, error) { + var c *client.ClineClient + var err error + + if address != "" { + c, err = global.GetClientForAddress(ctx, address) + } else { + c, err = global.GetDefaultClient(ctx) + } + + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + + // Get the actual address being used + clientAddress := address + if address == "" && global.Clients != nil { + clientAddress = global.Clients.GetRegistry().GetDefaultInstance() + } + + return &Manager{ + client: c, + clientAddress: clientAddress, + }, nil +} + +// GetCurrentInstance returns the address of the current instance +func (m *Manager) GetCurrentInstance() string { + return m.clientAddress +} + +func (m *Manager) UpdateSettings(ctx context.Context, settings *cline.Settings, secrets *cline.Secrets) error { + request := &cline.UpdateSettingsRequestCli{ + Metadata: &cline.Metadata{}, + Settings: settings, + Secrets: secrets, + } + + // Call the updateSettingsCli RPC + _, err := m.client.State.UpdateSettingsCli(ctx, request) + if err != nil { + return fmt.Errorf("failed to update settings: %w", err) + } + + fmt.Println("Settings updated successfully") + fmt.Printf("Instance: %s\n", m.clientAddress) + return nil +} + +func (m *Manager) GetState(ctx context.Context) (map[string]interface{}, error) { + state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to get state: %w", err) + } + + var stateData map[string]interface{} + if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil { + return nil, fmt.Errorf("failed to parse state: %w", err) + } + + return stateData, nil +} + +func (m *Manager) ListSettings(ctx context.Context) error { + // Get state + stateData, err := m.GetState(ctx) + if err != nil { + return err + } + + // Subset of fields we will print the values for + settingsFields := []string{ + "apiConfiguration", + "telemetrySetting", + "planActSeparateModelsSetting", + "enableCheckpointsSetting", + "mcpMarketplaceEnabled", + "shellIntegrationTimeout", + "terminalReuseEnabled", + "mcpResponsesCollapsed", + "mcpDisplayMode", + "terminalOutputLineLimit", + "mode", + "preferredLanguage", + "openaiReasoningEffort", + "strictPlanModeEnabled", + "focusChainSettings", + "useAutoCondense", + "customPrompt", + "browserSettings", + "defaultTerminalProfile", + "yoloModeToggled", + "dictationSettings", + "autoCondenseThreshold", + "autoApprovalSettings", + } + + // Render each field using the renderer + for _, field := range settingsFields { + if value, ok := stateData[field]; ok { + if err := RenderField(field, value, true); err != nil { + fmt.Printf("Error rendering %s: %v\n", field, err) + } + fmt.Println() + } + } + + return nil +} + +func (m *Manager) GetSetting(ctx context.Context, key string) error { + // Get state + stateData, err := m.GetState(ctx) + if err != nil { + return err + } + + // Convert kebab-case to camelCase path + parts := kebabToCamelPath(key) + rootField := parts[0] + + // Get the value + value, found := getNestedValue(stateData, parts) + if !found { + return fmt.Errorf("setting '%s' not found", key) + } + + // Render the value + if len(parts) == 1 { + // Top-level field: use RenderField for nice formatting + return RenderField(rootField, value, false) + } else { + // Nested field: simple print + fmt.Printf("%s: %s\n", key, formatValue(value, rootField, true)) + } + + return nil +} + +// kebabToCamelPath converts a kebab-case path to camelCase +// e.g., "auto-approval-settings.actions.read-files" -> "autoApprovalSettings.actions.readFiles" +func kebabToCamelPath(path string) []string { + parts := strings.Split(path, ".") + for i, part := range parts { + parts[i] = kebabToCamel(part) + } + return parts +} + +// kebabToCamel converts a single kebab-case string to camelCase +// e.g., "auto-approval-settings" -> "autoApprovalSettings" +func kebabToCamel(s string) string { + if s == "" { + return s + } + + parts := strings.Split(s, "-") + if len(parts) == 1 { + return s + } + + // First part stays lowercase, rest are capitalized + result := parts[0] + for i := 1; i < len(parts); i++ { + if parts[i] != "" { + result += strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + } + return result +} + +// getNestedValue retrieves a value from a nested map using dot notation +// e.g., "autoApprovalSettings.actions.readFiles" +func getNestedValue(data map[string]interface{}, parts []string) (interface{}, bool) { + current := interface{}(data) + + for _, part := range parts { + // Try to access as map + if m, ok := current.(map[string]interface{}); ok { + if val, exists := m[part]; exists { + current = val + continue + } + return nil, false + } + return nil, false + } + + return current, true +} diff --git a/cli/pkg/cli/config/settings_renderer.go b/cli/pkg/cli/config/settings_renderer.go new file mode 100644 index 00000000000..307d59b88fb --- /dev/null +++ b/cli/pkg/cli/config/settings_renderer.go @@ -0,0 +1,198 @@ +package config + +import ( + "fmt" + "strings" +) + +// sensitiveKeywords defines field name patterns that should be censored +var sensitiveKeywords = []string{"key", "secret", "password", "cline-account-id"} + +// camelToKebab converts camelCase to kebab-case +// e.g., "autoApprovalSettings" -> "auto-approval-settings" +func camelToKebab(s string) string { + if s == "" { + return s + } + + var result []rune + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result = append(result, '-') + } + result = append(result, r|32) // Convert to lowercase (works for A-Z) + } + return string(result) +} + +// isSensitiveField checks if a field name contains sensitive keywords +func isSensitiveField(fieldName string) bool { + if fieldName == "" { + return false + } + + lowerName := strings.ToLower(fieldName) + for _, keyword := range sensitiveKeywords { + if strings.Contains(lowerName, keyword) { + return true + } + } + + return false +} + +// formatValue formats a value for display, handling empty strings and censoring sensitive fields +func formatValue(val interface{}, fieldName string, censor bool) string { + // Handle empty strings specifically + if str, ok := val.(string); ok && str == "" { + return "''" + } + + if censor && isSensitiveField(fieldName) { + valStr := fmt.Sprintf("%v", val) + if valStr != "" && valStr != "''" { + return "********" + } + } + + return fmt.Sprintf("%v", val) +} + +// RenderField renders a single config field with proper formatting +func RenderField(key string, value interface{}, censor bool) error { + switch key { + // Nested objects - render with header + nested fields + case "apiConfiguration": + return renderApiConfiguration(value, censor) + case "browserSettings": + return renderBrowserSettings(value, censor) + case "focusChainSettings": + return renderFocusChainSettings(value, censor) + case "dictationSettings": + return renderDictationSettings(value, censor) + case "autoApprovalSettings": + return renderAutoApprovalSettings(value, censor) + + // Simple values - just print key: value + case "mode", "telemetrySetting", "preferredLanguage", "customPrompt", + "defaultTerminalProfile", "mcpDisplayMode", "openaiReasoningEffort", + "planActSeparateModelsSetting", "enableCheckpointsSetting", + "mcpMarketplaceEnabled", "terminalReuseEnabled", + "mcpResponsesCollapsed", "strictPlanModeEnabled", + "useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout", + "terminalOutputLineLimit", "autoCondenseThreshold": + fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor)) + return nil + + default: + return fmt.Errorf("unknown config field: %s", key) + } +} + +// renderApiConfiguration renders the API configuration object +func renderApiConfiguration(value interface{}, censor bool) error { + fmt.Println("api-configuration:") + + configMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid api-configuration format") + } + + // Print each field directly + for key, val := range configMap { + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + + return nil +} + +// renderBrowserSettings renders browser settings +func renderBrowserSettings(value interface{}, censor bool) error { + fmt.Println("browser-settings:") + + settingsMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid browser-settings format") + } + + // Handle nested viewport if present + if viewport, ok := settingsMap["viewport"].(map[string]interface{}); ok { + fmt.Println(" viewport:") + for key, val := range viewport { + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + } + + // Print other fields + for key, val := range settingsMap { + if key != "viewport" { + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + } + + return nil +} + +// renderFocusChainSettings renders focus chain settings +func renderFocusChainSettings(value interface{}, censor bool) error { + fmt.Println("focus-chain-settings:") + + settingsMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid focus-chain-settings format") + } + + for key, val := range settingsMap { + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + + return nil +} + +// renderDictationSettings renders dictation settings +func renderDictationSettings(value interface{}, censor bool) error { + fmt.Println("dictation-settings:") + + settingsMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid dictation-settings format") + } + + for key, val := range settingsMap { + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + + return nil +} + +// renderAutoApprovalSettings renders auto approval settings +func renderAutoApprovalSettings(value interface{}, censor bool) error { + fmt.Println("auto-approval-settings:") + + settingsMap, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid auto-approval-settings format") + } + + // Print top-level fields (skip version, handle actions specially) + for key, val := range settingsMap { + if key == "version" { + continue // Skip version + } + + if key == "actions" { + // Handle nested actions with double indentation + fmt.Println(" actions:") + if actionsMap, ok := val.(map[string]interface{}); ok { + for actionKey, actionVal := range actionsMap { + fmt.Printf(" %s: %s\n", camelToKebab(actionKey), formatValue(actionVal, actionKey, censor)) + } + } + } else { + // Print other fields normally (enabled, maxRequests, enableNotifications, favorites) + fmt.Printf(" %s: %s\n", camelToKebab(key), formatValue(val, key, censor)) + } + } + + return nil +} diff --git a/cli/pkg/cli/display/ansi.go b/cli/pkg/cli/display/ansi.go new file mode 100644 index 00000000000..4e29701f44a --- /dev/null +++ b/cli/pkg/cli/display/ansi.go @@ -0,0 +1,27 @@ +package display + +import ( + "fmt" + "os" + + "golang.org/x/term" +) + +func isTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func ClearLine() { + if !isTTY() { + return + } + fmt.Print("\r\033[K") +} + +// ClearToEnd clears from cursor to end of screen +func ClearToEnd() { + if !isTTY() { + return + } + fmt.Print("\033[J") +} diff --git a/cli/pkg/cli/display/banner.go b/cli/pkg/cli/display/banner.go new file mode 100644 index 00000000000..57b8f6b80ef --- /dev/null +++ b/cli/pkg/cli/display/banner.go @@ -0,0 +1,211 @@ +package display + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// BannerInfo contains information to display in the session banner +type BannerInfo struct { + Version string + Provider string + ModelID string + Workdir string + Mode string +} + +// RenderSessionBanner renders a nice banner showing version, model, and workspace info +func RenderSessionBanner(info BannerInfo) string { + // Bright white for title + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). // Bright white + Bold(true) + + // Dim gray for regular text (same as huh placeholder) + dimStyle := lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"}) + + // Border color matches mode + borderColor := lipgloss.Color("3") // Yellow for plan + if info.Mode == "act" { + borderColor = lipgloss.Color("39") // Blue for act + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(1, 4) + + var lines []string + + // Format version with "v" prefix if it starts with a number + versionStr := info.Version + if len(versionStr) > 0 && versionStr[0] >= '0' && versionStr[0] <= '9' { + versionStr = "v" + versionStr + } + + // First line: "cline cli vX.X.X" on left, "plan mode" on right + leftSide := titleStyle.Render("cline cli preview") + " " + dimStyle.Render(versionStr) + + if info.Mode != "" { + modeColor := lipgloss.Color("3") // Yellow for plan + if info.Mode == "act" { + modeColor = lipgloss.Color("39") // Blue for act + } + modeStyle := lipgloss.NewStyle().Foreground(modeColor).Bold(true) + rightSide := modeStyle.Render(info.Mode + " mode") + + // Calculate spacing to push mode to the right + // Assume a reasonable width (we'll adjust based on content) + lineWidth := 50 + leftWidth := lipgloss.Width(leftSide) + rightWidth := lipgloss.Width(rightSide) + spacing := lineWidth - leftWidth - rightWidth + + if spacing > 0 { + titleLine := leftSide + strings.Repeat(" ", spacing) + rightSide + lines = append(lines, titleLine) + } else { + // If too narrow, just put them on same line with a space + lines = append(lines, leftSide+" "+rightSide) + } + } else { + // No mode, just show title + lines = append(lines, leftSide) + } + + // Model line - dim gray + if info.Provider != "" && info.ModelID != "" { + lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30))) + } + + // Workspace line - dim gray + if info.Workdir != "" { + lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45))) + } + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + return boxStyle.Render(content) +} + +// shortenPath shortens a filesystem path to fit within maxLen +func shortenPath(path string, maxLen int) string { + // Try to replace home directory with ~ (cross-platform) + if homeDir, err := os.UserHomeDir(); err == nil { + if strings.HasPrefix(path, homeDir) { + shortened := "~" + path[len(homeDir):] + // Always use ~ version if we can + path = shortened + } + } + + if len(path) <= maxLen { + return path + } + + // If still too long, show last few path components + if len(path) > maxLen { + parts := strings.Split(path, string(filepath.Separator)) + if len(parts) > 2 { + // Show last 2-3 components + lastParts := parts[len(parts)-2:] + shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator)) + if len(shortened) <= maxLen { + return shortened + } + } + } + + // Last resort: truncate with ellipsis + if len(path) > maxLen { + return "..." + path[len(path)-maxLen+3:] + } + + return path +} + +// ExtractBannerInfoFromState extracts banner info from state JSON +func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) { + var state map[string]interface{} + if err := json.Unmarshal([]byte(stateJSON), &state); err != nil { + return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err) + } + + info := BannerInfo{ + Version: version, + } + + // Extract mode + if mode, ok := state["mode"].(string); ok { + info.Mode = mode + } + + // Extract workspace roots + if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 { + if root, ok := workspaceRoots[0].(map[string]interface{}); ok { + if path, ok := root["path"].(string); ok { + info.Workdir = path + } + } + } + + // Extract API configuration to get provider/model + if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok { + // Try common keys for provider and model (both camelCase and lowercase variants) + providerKeys := []string{"apiProvider", "api_provider"} + modelKeys := []string{"apiModelId", "api_model_id"} + + // Try to extract provider + for _, key := range providerKeys { + if provider, ok := apiConfig[key].(string); ok && provider != "" { + info.Provider = provider + break + } + } + + // Try to extract model ID + for _, key := range modelKeys { + if modelID, ok := apiConfig[key].(string); ok && modelID != "" { + info.ModelID = shortenModelID(modelID) + break + } + } + } + + return info, nil +} + +// shortenModelID shortens long model IDs for display +func shortenModelID(modelID string) string { + // Remove date suffixes only if they're at the end (e.g., -20241022) + // Check if the model ID ends with -YYYYMMDD pattern + if len(modelID) > 9 { + suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022 + if suffix[0] == '-' && + (strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) { + // Verify all remaining chars are digits + allDigits := true + for _, c := range suffix[1:] { + if c < '0' || c > '9' { + allDigits = false + break + } + } + if allDigits { + return modelID[:len(modelID)-9] + } + } + } + + // If still too long, show first 40 chars + if len(modelID) > 40 { + return modelID[:37] + "..." + } + + return modelID +} diff --git a/cli/pkg/cli/display/markdown_renderer.go b/cli/pkg/cli/display/markdown_renderer.go new file mode 100644 index 00000000000..47982ddbced --- /dev/null +++ b/cli/pkg/cli/display/markdown_renderer.go @@ -0,0 +1,139 @@ +package display + +import ( + "os" + "strconv" + "strings" + + "fmt" + + "github.com/charmbracelet/glamour" + "golang.org/x/term" +) + +type MarkdownRenderer struct { + renderer *glamour.TermRenderer + width int +} + +// i went back and forth on whether or not to enable word wrap +// setting line width to 0 enables the terminal to handle wrapping +// setting it to a terminal width enables glamour's word wrap +// the thing is, glamour's nice indentation looks really good, and +// won't work without glamour's word wrap - if you use the terminal's +// word wrap, the indentation looks weird so you have to turn it off +// and everything will be right next to the left margin +// but if you DO use glamours word wrap, it also means if you resize the terminal, +// it will scuff everything. but given that this is the case for the input anyway, +// i figure we just make things as beautiful as possible +// and if you resize the terminal, you'll learn real quick. +// anyway, you can set this to true or false to experiment +const USETERMINALWORDWRAP = true + + +// seems like a reliable way to check for terminals +// for now i'm keeping everything as auto +// eventually we can define a custom glamour style for ghostty / iterm +// https://github.com/charmbracelet/glamour/blob/master/styles/README.md) +func detectTerminalTheme() string { + switch os.Getenv("TERM_PROGRAM") { + case "iTerm.app", "Ghostty": + return "dark" + } + if os.Getenv("GHOSTTY_VERSION") != "" { + return "dark" + } + return "dark" +} + +func glamourStyleJSON(terminalWrap bool) string { + const tmpl = `{ + "document": { + "block_prefix": "\n", + "block_suffix": "\n", + "color": "252", + "margin": %s + }, + "code_block": { + "margin": 0 + } + }` + if terminalWrap { + return fmt.Sprintf(tmpl, "0") + } + return fmt.Sprintf(tmpl, "2") +} + + + + +func NewMarkdownRenderer() (*MarkdownRenderer, error) { + var wordWrap int + if USETERMINALWORDWRAP { + // terminal handles wrapping -> disable glamour wrap + wordWrap = 0 + } else { + // glamour handles wrapping -> set to current width + wordWrap = terminalWidthOr(0) + } + + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first + glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins + glamour.WithWordWrap(wordWrap), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return nil, err + } + + return &MarkdownRenderer{ + renderer: r, + width: 0, // Unlimited width + }, nil +} + +// terminalWidthOr returns the terminal width or the provided fallback. +// It first tries term.GetSize, then falls back to $COLUMNS if set. +func terminalWidthOr(fallback int) int { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + return w + } + if cols := os.Getenv("COLUMNS"); cols != "" { + if n, err := strconv.Atoi(cols); err == nil && n > 0 { + return n + } + } + return fallback +} + +// NewMarkdownRendererWithWidth creates a markdown renderer with a specific width. +// Useful for tables and other content that should fit within terminal bounds. +func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) { + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle(detectTerminalTheme()), + glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(false))), + glamour.WithWordWrap(width), + glamour.WithPreservedNewLines(), + ) + if err != nil { + return nil, err + } + return &MarkdownRenderer{renderer: r, width: width}, nil +} + + +// NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width. +// Falls back to 120 if terminal width cannot be determined. +func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) { + width := terminalWidthOr(120) + return NewMarkdownRendererWithWidth(width) +} + +func (mr *MarkdownRenderer) Render(markdown string) (string, error) { + rendered, err := mr.renderer.Render(markdown) + if err != nil { + return "", err + } + return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil +} diff --git a/cli/pkg/cli/display/renderer.go b/cli/pkg/cli/display/renderer.go index 62068588e9a..e267b534a5d 100644 --- a/cli/pkg/cli/display/renderer.go +++ b/cli/pkg/cli/display/renderer.go @@ -3,44 +3,69 @@ package display import ( "fmt" "strings" - "time" + "github.com/charmbracelet/lipgloss" "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/output" "github.com/cline/cli/pkg/cli/types" "github.com/cline/grpc-go/cline" ) type Renderer struct { - typewriter *TypewriterPrinter + typewriter *TypewriterPrinter + mdRenderer *MarkdownRenderer + outputFormat string + + // Lipgloss styles that respect outputFormat + dimStyle lipgloss.Style + greenStyle lipgloss.Style + redStyle lipgloss.Style + yellowStyle lipgloss.Style + blueStyle lipgloss.Style + whiteStyle lipgloss.Style + boldStyle lipgloss.Style + successStyle lipgloss.Style } -func NewRenderer() *Renderer { - return &Renderer{ - typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()), +func NewRenderer(outputFormat string) *Renderer { + mdRenderer, err := NewMarkdownRenderer() + if err != nil { + mdRenderer = nil } + + r := &Renderer{ + typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()), + mdRenderer: mdRenderer, + outputFormat: outputFormat, + } + + // Initialize lipgloss styles (will respect the global color profile) + r.dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + r.greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + r.redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + r.yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + r.blueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + r.whiteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) + r.boldStyle = lipgloss.NewStyle().Bold(true) + r.successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) + + return r } -// RenderMessage renders a message with timestamp and prefix -func (r *Renderer) RenderMessage(timestamp, prefix, text string) error { +func (r *Renderer) RenderMessage(prefix, text string, newline bool) error { if text == "" { return nil } - cleanText := r.sanitizeText(text) - if cleanText == "" { + clean := r.sanitizeText(text) + if clean == "" { return nil } - r.typewriter.PrintMessageLine(timestamp, prefix, cleanText) - return nil -} - -// RenderCommand renders a command execution -func (r *Renderer) RenderCommand(timestamp, command string, isExecuting bool) error { - if isExecuting { - r.typewriter.PrintMessageLine(timestamp, "EXEC", command) + if newline { + output.Printf("%s: %s\n", prefix, clean) } else { - r.typewriter.PrintMessageLine(timestamp, "CMD", command) + output.Printf("%s: %s", prefix, clean) } return nil } @@ -57,34 +82,58 @@ func formatNumber(n int) string { // formatUsageInfo formats token usage information (extracted from RenderAPI) func (r *Renderer) formatUsageInfo(tokensIn, tokensOut, cacheReads, cacheWrites int, cost float64) string { - tokenDetails := fmt.Sprintf("[tokens in: %s, out: %s; cache read: %s, write: %s]", - formatNumber(tokensIn), - formatNumber(tokensOut), - formatNumber(cacheReads), - formatNumber(cacheWrites)) - - return fmt.Sprintf("%s ($%.4f)", tokenDetails, cost) + 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) } -// RenderAPI renders API request information -func (r *Renderer) RenderAPI(timestamp, status string, apiInfo *types.APIRequestInfo) error { + +func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error { if apiInfo.Cost >= 0 { - message := fmt.Sprintf("%s %s", status, r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)) - r.typewriter.PrintMessageLine(timestamp, "API INFO", message) + usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost) + markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo) + rendered := r.RenderMarkdown(markdown) + output.Print(rendered) } else { - r.typewriter.PrintMessageLine(timestamp, "API INFO", status) + // honestly i see no point in showing "### API processing request" here... + // markdown := fmt.Sprintf("## API %s", status) + // rendered := r.RenderMarkdown(markdown) + // output.Printf("\n%s\n", rendered) } return nil } -// RenderRetry renders retry information -func (r *Renderer) RenderRetry(timestamp string, attempt, maxAttempts, delaySec int) error { +func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error { message := fmt.Sprintf("Retrying failed attempt %d/%d", attempt, maxAttempts) if delaySec > 0 { message += fmt.Sprintf(" in %d seconds", delaySec) } message += "..." - r.typewriter.PrintMessageLine(timestamp, "API INFO", message) + r.typewriter.PrintMessageLine("API INFO", message) + return nil +} + +func (r *Renderer) RenderTaskCancelled() error { + markdown := "## Task cancelled" + rendered := r.RenderMarkdown(markdown) + output.Printf("\n%s\n", rendered) return nil } @@ -101,16 +150,16 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error { r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks)) - for i, task := range recentTasks { - r.typewriter.PrintfLn("Task ID: %s", task.Id) + for i, taskItem := range recentTasks { + r.typewriter.PrintfLn("Task ID: %s", taskItem.Id) - description := task.Task + description := taskItem.Task if len(description) > 1000 { description = description[:1000] + "..." } r.typewriter.PrintfLn("Message: %s", description) - usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost) + usageInfo := r.formatUsageInfo(int(taskItem.TokensIn), int(taskItem.TokensOut), int(taskItem.CacheReads), int(taskItem.CacheWrites), taskItem.TotalCost) r.typewriter.PrintfLn("Usage : %s", usageInfo) // Single space between tasks (except last) @@ -124,19 +173,18 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error { func (r *Renderer) RenderDebug(format string, args ...interface{}) error { if global.Config.Verbose { - timestamp := time.Now().Format("15:04:05") message := fmt.Sprintf(format, args...) - r.typewriter.PrintMessageLine(timestamp, "[DEBUG]", message) + r.typewriter.PrintMessageLine("[DEBUG]", message) } return nil } func (r *Renderer) ClearLine() { - fmt.Print("\r\033[K") + output.Print("\r\033[K") } func (r *Renderer) MoveCursorUp(n int) { - fmt.Printf("\033[%dA", n) + output.Printf("\033[%dA", n) } func (r *Renderer) sanitizeText(text string) string { @@ -174,3 +222,83 @@ func (r *Renderer) SetTypewriterSpeed(multiplier float64) { func (r *Renderer) GetTypewriter() *TypewriterPrinter { return r.typewriter } + +func (r *Renderer) GetMdRenderer() *MarkdownRenderer { + return r.mdRenderer +} + +// RenderMarkdown renders markdown text to terminal format with ANSI codes +// Falls back to plaintext if markdown rendering is unavailable or fails +// Respects output format - skips rendering in plain mode or non-TTY contexts +func (r *Renderer) RenderMarkdown(markdown string) string { + // Skip markdown rendering if: + // 1. Output format is explicitly "plain" + // 2. Not in a TTY (piped output, file redirect, CI, etc.) + if r.outputFormat == "plain" || !isTTY() { + return markdown + } + + if r.mdRenderer == nil { + return markdown + } + + rendered, err := r.mdRenderer.Render(markdown) + if err != nil { + return markdown + } + + return rendered +} + +// Lipgloss-based color rendering methods +// These automatically respect the output format via lipgloss color profile + +// Dim renders text in dim gray (bright black) +func (r *Renderer) Dim(text string) string { + return r.dimStyle.Render(text) +} + +// Green renders text in green +func (r *Renderer) Green(text string) string { + return r.greenStyle.Render(text) +} + +// Red renders text in red +func (r *Renderer) Red(text string) string { + return r.redStyle.Render(text) +} + +// Yellow renders text in yellow +func (r *Renderer) Yellow(text string) string { + return r.yellowStyle.Render(text) +} + +// Blue renders text in 256-color blue (index 39) +func (r *Renderer) Blue(text string) string { + return r.blueStyle.Render(text) +} + +// White renders text in white +func (r *Renderer) White(text string) string { + return r.whiteStyle.Render(text) +} + +// Bold renders text in bold +func (r *Renderer) Bold(text string) string { + return r.boldStyle.Render(text) +} + +// Success renders text in green with bold +func (r *Renderer) Success(text string) string { + return r.successStyle.Render(text) +} + +// SuccessWithCheckmark renders text in green with bold and a checkmark prefix +func (r *Renderer) SuccessWithCheckmark(text string) string { + return r.Success("✓ " + text) +} + +// ErrorWithX renders text in red with an X prefix +func (r *Renderer) ErrorWithX(text string) string { + return r.Red("✗ " + text) +} diff --git a/cli/pkg/cli/display/segment_streamer.go b/cli/pkg/cli/display/segment_streamer.go new file mode 100644 index 00000000000..552f39d5a96 --- /dev/null +++ b/cli/pkg/cli/display/segment_streamer.go @@ -0,0 +1,212 @@ +package display + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/cline/cli/pkg/cli/output" + "github.com/cline/cli/pkg/cli/types" +) + +type StreamingSegment struct { + mu sync.Mutex + sayType string + prefix string + buffer strings.Builder + frozen bool + mdRenderer *MarkdownRenderer + toolRenderer *ToolRenderer + shouldMarkdown bool + outputFormat string + msg *types.ClineMessage + toolParser *ToolResultParser +} + +func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment { + ss := &StreamingSegment{ + sayType: sayType, + prefix: prefix, + mdRenderer: mdRenderer, + toolRenderer: NewToolRenderer(mdRenderer, outputFormat), + shouldMarkdown: shouldMarkdown, + outputFormat: outputFormat, + msg: msg, + toolParser: NewToolResultParser(mdRenderer), + } + + // Render rich header immediately when creating segment (if in rich mode and TTY) + if shouldMarkdown && outputFormat != "plain" && isTTY() { + header := ss.generateRichHeader() + rendered, _ := mdRenderer.Render(header) + output.Println("") + output.Print(rendered) + } + + return ss +} + +func (ss *StreamingSegment) AppendText(text string) { + ss.mu.Lock() + defer ss.mu.Unlock() + + if ss.frozen { + return + } + + // Replace buffer with FULL text - msg.Text contains complete accumulated content + ss.buffer.Reset() + ss.buffer.WriteString(text) + + // No rendering during streaming - we'll render once on Freeze() +} + + +func (ss *StreamingSegment) Freeze() { + ss.mu.Lock() + defer ss.mu.Unlock() + + if ss.frozen { + return + } + + ss.frozen = true + currentBuffer := ss.buffer.String() + + // Render and print the final markdown + ss.renderFinal(currentBuffer) +} + +func (ss *StreamingSegment) renderFinal(currentBuffer string) { + var bodyContent string + + // Use ToolRenderer for all body rendering to centralize logic + if ss.sayType == "ask" { + // Handle ASK messages + if ss.msg.Ask == string(types.AskTypeTool) { + // Tool approval: use ToolRenderer for body + var tool types.ToolMessage + if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil { + // For approval requests in streaming, use the preview method + bodyContent = ss.toolRenderer.GenerateToolContentPreview(&tool) + } + } else if ss.msg.Ask == string(types.AskTypeFollowup) { + // Followup question: use ToolRenderer + bodyContent = ss.toolRenderer.GenerateAskFollowupBody(currentBuffer) + } else if ss.msg.Ask == string(types.AskTypePlanModeRespond) { + // Plan mode respond: use ToolRenderer + bodyContent = ss.toolRenderer.GeneratePlanModeRespondBody(currentBuffer) + } else if ss.msg.Ask == string(types.AskTypeCommand) { + // Command approval: no body needed - header shows command, output shown separately later + bodyContent = "" + } else { + // For other ask types, render as-is + bodyContent = currentBuffer + } + } else if ss.sayType == string(types.SayTypeTool) { + // Tool execution (SAY): use ToolRenderer for body + var tool types.ToolMessage + if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil { + bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool) + } + } else if ss.sayType == string(types.SayTypeCommand) { + // Command output + bodyContent = "```shell\n" + currentBuffer + "\n```" + // Render markdown only in rich mode and TTY + if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() { + rendered, err := ss.mdRenderer.Render(bodyContent) + if err == nil { + bodyContent = rendered + } + } + } else { + // For other types (reasoning, text, etc.), render markdown as-is + if ss.shouldMarkdown && ss.outputFormat != "plain" && isTTY() { + rendered, err := ss.mdRenderer.Render(currentBuffer) + if err == nil { + bodyContent = rendered + } else { + bodyContent = currentBuffer + } + } else { + bodyContent = currentBuffer + } + } + + // Print the body content + if bodyContent != "" { + if !strings.HasSuffix(bodyContent, "\n") { + output.Print(bodyContent) + output.Println("") + } else { + output.Print(bodyContent) + } + } +} + + +// generateRichHeader generates a contextual header for the segment +func (ss *StreamingSegment) generateRichHeader() string { + switch ss.sayType { + case string(types.SayTypeReasoning): + return "### Cline is thinking\n" + + case string(types.SayTypeText): + return "### Cline responds\n" + + case string(types.SayTypeCompletionResult): + return "### Task completed\n" + + case string(types.SayTypeTool): + return ss.generateToolHeader() + + case "ask": + // Check the specific ask type + if ss.msg.Ask == string(types.AskTypePlanModeRespond) { + return ss.toolRenderer.GeneratePlanModeRespondHeader() + } + + // For tool approvals, show proper tool header + if ss.msg.Ask == string(types.AskTypeTool) { + var tool types.ToolMessage + if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err == nil { + // Use ToolRenderer for approval header with "wants to" verbs + return ss.toolRenderer.RenderToolApprovalHeader(&tool) + } + } + + // For command approvals, show command header + if ss.msg.Ask == string(types.AskTypeCommand) { + command := strings.TrimSpace(ss.msg.Text) + if strings.HasSuffix(command, "REQ_APP") { + command = strings.TrimSuffix(command, "REQ_APP") + command = strings.TrimSpace(command) + } + return fmt.Sprintf("### Cline wants to run `%s`\n", command) + } + + // For followup questions, show question header + if ss.msg.Ask == string(types.AskTypeFollowup) { + return ss.toolRenderer.GenerateAskFollowupHeader() + } + + // For other ask types, show generic message + return fmt.Sprintf("### Cline is asking (%s)\n", ss.msg.Ask) + + default: + return fmt.Sprintf("### %s\n", ss.prefix) + } +} + +// generateToolHeader generates a contextual header for tool operations +func (ss *StreamingSegment) generateToolHeader() string { + // Parse tool JSON from message text + var tool types.ToolMessage + if err := json.Unmarshal([]byte(ss.msg.Text), &tool); err != nil { + return "### Tool operation\n" + } + + // Use unified ToolRenderer for header + return ss.toolRenderer.RenderToolExecutionHeader(&tool) +} diff --git a/cli/pkg/cli/display/streaming.go b/cli/pkg/cli/display/streaming.go index 2923cff8f78..36f8b1fb066 100644 --- a/cli/pkg/cli/display/streaming.go +++ b/cli/pkg/cli/display/streaming.go @@ -1,7 +1,6 @@ package display import ( - "encoding/json" "fmt" "strings" "sync" @@ -11,18 +10,26 @@ import ( // StreamingDisplay manages streaming message display with deduplication type StreamingDisplay struct { - mu sync.RWMutex - state *types.ConversationState - renderer *Renderer - dedupe *MessageDeduplicator + mu sync.RWMutex + state *types.ConversationState + renderer *Renderer + dedupe *MessageDeduplicator + activeSegment *StreamingSegment + mdRenderer *MarkdownRenderer } // NewStreamingDisplay creates a new streaming display manager func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *StreamingDisplay { + mdRenderer, err := NewMarkdownRenderer() + if err != nil { + panic(fmt.Sprintf("Failed to initialize markdown renderer: %v", err)) + } + return &StreamingDisplay{ - state: state, - renderer: renderer, - dedupe: NewMessageDeduplicator(), + state: state, + renderer: renderer, + dedupe: NewMessageDeduplicator(), + mdRenderer: mdRenderer, } } @@ -31,418 +38,96 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error { s.mu.Lock() defer s.mu.Unlock() - messageKey := fmt.Sprintf("%d", msg.Timestamp) - timestamp := msg.GetTimestamp() - // Check for deduplication if s.dedupe.IsDuplicate(msg) { return nil } - // Get current streaming state - streamingMsg := s.state.GetStreamingMessage() - - switch msg.Type { - case types.MessageTypeAsk: - return s.handleStreamingAsk(msg, messageKey, timestamp, streamingMsg) - case types.MessageTypeSay: - return s.handleStreamingSay(msg, messageKey, timestamp, streamingMsg) - default: - return s.renderer.RenderMessage(timestamp, "🤖", msg.Text) + // 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" } -} -// handleStreamingAsk handles streaming ASK messages -func (s *StreamingDisplay) handleStreamingAsk(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - if msg.Text == "" { - return nil + // Detect segment boundary + if s.activeSegment != nil && s.activeSegment.sayType != sayType { + // Just cleanup, don't freeze (no body to print) + s.activeSegment = nil } - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { + // 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 } - // Check if this is an update to the same ASK message - if streamingMsg.CurrentKey == messageKey { - // This is an update to the same ASK message - stream the changes - if cleanText != streamingMsg.LastText { - s.streamAskMessageUpdate(cleanText, streamingMsg.LastText, timestamp) - s.state.SetStreamingMessage(messageKey, cleanText) - } - } else { - // This is a new ASK message - s.finishCurrentStream() - s.streamAskMessage(cleanText, timestamp, true) - s.state.SetStreamingMessage(messageKey, cleanText) - } - - return nil -} - -// handleStreamingSay handles streaming SAY messages -func (s *StreamingDisplay) handleStreamingSay(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - switch msg.Say { - case string(types.SayTypeText), string(types.SayTypeCompletionResult): - return s.handleStreamingText(msg, messageKey, timestamp, streamingMsg) - case string(types.SayTypeCommand): - return s.handleStreamingCommand(msg, messageKey, timestamp, streamingMsg) - case string(types.SayTypeCommandOutput): - return s.handleStreamingCommandOutput(msg, messageKey, timestamp, streamingMsg) - case string(types.SayTypeTool): - return s.handleStreamingTool(msg, messageKey, timestamp, streamingMsg) - case string(types.SayTypeShellIntegrationWarning): - return s.handleShellIntegrationWarning(msg, messageKey, timestamp, streamingMsg) - default: - // For non-streaming message types, use regular display - return s.renderer.RenderMessage(timestamp, s.getMessagePrefix(msg.Say), msg.Text) - } -} - -// handleStreamingText handles streaming text messages -func (s *StreamingDisplay) handleStreamingText(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { + // For subsequent partial messages, do nothing (header already shown) + if msg.Partial { return nil } - // Check if we've already displayed this exact message - if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText { - return nil // Duplicate - ignore it - } - - // Check if this is an update to the same message - if streamingMsg.CurrentKey == messageKey { - // Show incremental changes - if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) { - // Show only the new characters with typewriter effect - newChars := cleanText[len(streamingMsg.LastText):] - s.typewriterPrint(newChars) - s.state.SetStreamingMessage(messageKey, cleanText) - } else { - // Text changed in a non-incremental way - replace the line - s.renderer.ClearLine() - prefix := s.getMessagePrefix(msg.Say) - s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix) - s.typewriterPrint(cleanText) - s.state.SetStreamingMessage(messageKey, cleanText) - } - } else { - // This is a new message - s.finishCurrentStream() - prefix := s.getMessagePrefix(msg.Say) - s.renderer.typewriter.PrintfInstant("[%s] %s: ", timestamp, prefix) - - // Add typewriter animation for new messages - s.typewriterPrint(cleanText) - - s.state.SetStreamingMessage(messageKey, cleanText) - } - - // If message is complete, add newline - if !msg.Partial { - fmt.Println() - s.state.SetStreamingMessage("", "") + // 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 } -// handleStreamingCommand handles command execution messages -func (s *StreamingDisplay) handleStreamingCommand(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { - return nil - } - - // Show command being executed with typewriter effect - s.finishCurrentStream() - s.renderer.typewriter.PrintfInstant("[%s] 🖥️ CMD: ", timestamp) - s.typewriterPrint(cleanText) - fmt.Println() - - return nil -} - -// handleStreamingCommandOutput handles streaming command output -func (s *StreamingDisplay) handleStreamingCommandOutput(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { - return nil - } - - // Check if we've already displayed this exact message - if streamingMsg.CurrentKey == messageKey && streamingMsg.LastText == cleanText { - return nil - } - - // Check if this is an update to the same message - if streamingMsg.CurrentKey == messageKey { - // Show incremental changes with typewriter effect - if len(cleanText) > len(streamingMsg.LastText) && strings.HasPrefix(cleanText, streamingMsg.LastText) { - newChars := cleanText[len(streamingMsg.LastText):] - s.typewriterPrint(newChars) - s.state.SetStreamingMessage(messageKey, cleanText) - } else { - // Non-incremental change - replace the line - s.renderer.ClearLine() - s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp) - s.typewriterPrint(cleanText) - s.state.SetStreamingMessage(messageKey, cleanText) - } - } else { - // New command output message - s.finishCurrentStream() - s.renderer.typewriter.PrintfInstant("[%s] 🖥️ OUT: ", timestamp) - s.typewriterPrint(cleanText) - s.state.SetStreamingMessage(messageKey, cleanText) - } - - // If message is complete, add newline - if !msg.Partial { - fmt.Println() - s.state.SetStreamingMessage("", "") - } - - return nil -} - -// handleShellIntegrationWarning handles shell integration warning messages -func (s *StreamingDisplay) handleShellIntegrationWarning(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { - return nil - } - - // Show a more concise shell integration warning - s.finishCurrentStream() - s.renderer.typewriter.PrintfInstant("[%s] ℹ️ NOTE: ", timestamp) - s.typewriterPrint("Command executed (output not streamed due to shell integration)") - fmt.Println() - - return nil -} - -// handleStreamingTool handles streaming tool messages with deduplication -func (s *StreamingDisplay) handleStreamingTool(msg *types.ClineMessage, messageKey, timestamp string, streamingMsg *types.StreamingMessage) error { - cleanText := s.renderer.sanitizeText(msg.Text) - if cleanText == "" { - return nil - } - - formattedTool := s.formatToolMessage(cleanText) - - // Check if this is the exact same tool message we just displayed - if streamingMsg.LastToolMessage == formattedTool { - return nil // Exact duplicate - ignore it - } - - // Check if this is a very similar tool message - if streamingMsg.LastToolMessage != "" && s.isSimilarToolMessage(streamingMsg.LastToolMessage, formattedTool) { - return nil // Similar duplicate - ignore it - } - - // This is a genuinely new/different tool message - s.finishCurrentStream() - fmt.Printf("[%s] 🔧 TOOL: %s\n", timestamp, formattedTool) - - // Store the formatted tool message for deduplication - s.state.StreamingMessage.LastToolMessage = formattedTool - - return nil -} - -// streamAskMessage streams an ASK message in a natural format -func (s *StreamingDisplay) streamAskMessage(text, timestamp string, isNew bool) { - // Try to parse as JSON - var askData types.AskData - if err := s.parseJSON(text, &askData); err != nil { - // Display as text but sanitized - fmt.Printf("[%s] 🤖 ASK: %s", timestamp, text) - return - } - - fmt.Printf("[%s] 🤖 ASK: %s", timestamp, askData.Response) - - // Display options if available - if len(askData.Options) > 0 { - fmt.Print("\n\nOptions:") - for i, option := range askData.Options { - fmt.Printf("\n%d. %s", i+1, option) - } - } -} - -// streamAskMessageUpdate handles updates to an existing ASK message -func (s *StreamingDisplay) streamAskMessageUpdate(newText, oldText, timestamp string) { - var oldAskData, newAskData types.AskData - - oldErr := s.parseJSON(oldText, &oldAskData) - newErr := s.parseJSON(newText, &newAskData) - - if oldErr != nil || newErr != nil { - // Handle plain text incremental updates - if len(newText) > len(oldText) && strings.HasPrefix(newText, oldText) { - newChars := newText[len(oldText):] - fmt.Print(newChars) - } else { - // Non-incremental change - clear line and reprint everything - s.renderer.ClearLine() - fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newText) - } - return - } - - // Handle structured updates - if len(newAskData.Response) > len(oldAskData.Response) && strings.HasPrefix(newAskData.Response, oldAskData.Response) { - newChars := newAskData.Response[len(oldAskData.Response):] - fmt.Print(newChars) - } else if oldAskData.Response != newAskData.Response { - s.renderer.ClearLine() - fmt.Printf("[%s] 🤖 ASK: %s", timestamp, newAskData.Response) - } - - // Handle options changes - if len(newAskData.Options) > len(oldAskData.Options) { - if len(oldAskData.Options) == 0 { - fmt.Print("\n\nOptions:") - } - - for i := len(oldAskData.Options); i < len(newAskData.Options); i++ { - fmt.Printf("\n%d. %s", i+1, newAskData.Options[i]) - } - } -} - -// typewriterPrint displays text with a typewriter animation effect -func (s *StreamingDisplay) typewriterPrint(text string) { - // Use the renderer's typewriter for consistent animation - s.renderer.typewriter.Print(text) -} - -// finishCurrentStream completes any ongoing streaming message -func (s *StreamingDisplay) finishCurrentStream() { - streamingMsg := s.state.GetStreamingMessage() - if streamingMsg.CurrentKey != "" { - //fmt.Println() // Add newline to finish the current streaming message - s.state.SetStreamingMessage("", "") - } -} - -// getMessagePrefix returns the appropriate prefix for a message type -func (s *StreamingDisplay) getMessagePrefix(say string) string { - switch say { - case string(types.SayTypeCompletionResult): - return "✅ RESULT" - case string(types.SayTypeText): - return "🤖" +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 "🤖" - } -} - -// formatToolMessage formats tool call messages for better readability -func (s *StreamingDisplay) formatToolMessage(text string) string { - var toolCall map[string]interface{} - if err := s.parseJSON(text, &toolCall); err == nil { - if tool, ok := toolCall["tool"].(string); ok { - parts := []string{tool} - - if path, ok := toolCall["path"].(string); ok && path != "" { - parts = append(parts, fmt.Sprintf("path=%s", path)) - } - - if content, ok := toolCall["content"].(string); ok && content != "" { - if len(content) > 50 { - parts = append(parts, fmt.Sprintf("content=%s...", content[:50])) - } else { - parts = append(parts, fmt.Sprintf("content=%s", content)) - } - } - - return strings.Join(parts, " ") - } - } - - // If not JSON or doesn't have expected structure, return truncated - if len(text) > 100 { - return text[:100] + "..." - } - return text -} - -// isSimilarToolMessage checks if two tool messages are similar enough to be considered duplicates -func (s *StreamingDisplay) isSimilarToolMessage(msg1, msg2 string) bool { - parts1 := strings.Fields(msg1) - parts2 := strings.Fields(msg2) - - if len(parts1) == 0 || len(parts2) == 0 { return false } - - // If the first word (tool name) is the same, check for similarity - if parts1[0] == parts2[0] { - // For file operations, check if the path is the same - if strings.Contains(msg1, "path=") && strings.Contains(msg2, "path=") { - path1 := s.extractPathFromToolMessage(msg1) - path2 := s.extractPathFromToolMessage(msg2) - - if path1 != "" && path1 == path2 { - return true - } - } - - // For very similar content (>80% similarity), consider them duplicates - similarity := s.calculateStringSimilarity(msg1, msg2) - return similarity > 0.8 - } - - return false } -// extractPathFromToolMessage extracts the path parameter from a tool message -func (s *StreamingDisplay) extractPathFromToolMessage(msg string) string { - parts := strings.Fields(msg) - for _, part := range parts { - if strings.HasPrefix(part, "path=") { - return strings.TrimPrefix(part, "path=") - } +func (s *StreamingDisplay) getPrefix(sayType string) string { + switch sayType { + case string(types.SayTypeReasoning): + return "THINKING" + case string(types.SayTypeText): + return "CLINE" + case string(types.SayTypeCompletionResult): + return "RESULT" + case "ask": + return "ASK" + case string(types.SayTypeCommand): + return "TERMINAL" + default: + return strings.ToUpper(sayType) } - return "" } -// calculateStringSimilarity calculates a simple similarity ratio between two strings -func (s *StreamingDisplay) calculateStringSimilarity(s1, s2 string) float64 { - if s1 == s2 { - return 1.0 - } - - if len(s1) == 0 || len(s2) == 0 { - return 0.0 - } - - shorter, longer := s1, s2 - if len(s1) > len(s2) { - shorter, longer = s2, s1 - } +func (s *StreamingDisplay) FreezeActiveSegment() { + s.mu.Lock() + defer s.mu.Unlock() - matches := 0 - for i, r := range shorter { - if i < len(longer) && rune(longer[i]) == r { - matches++ - } + if s.activeSegment != nil { + s.activeSegment.Freeze() + s.activeSegment = nil } - - return float64(matches) / float64(len(longer)) -} - -// parseJSON is a helper function to parse JSON with error handling -func (s *StreamingDisplay) parseJSON(text string, v interface{}) error { - return json.Unmarshal([]byte(text), v) } // Cleanup cleans up streaming display resources func (s *StreamingDisplay) Cleanup() { + s.FreezeActiveSegment() if s.dedupe != nil { s.dedupe.Stop() } diff --git a/cli/pkg/cli/display/system_renderer.go b/cli/pkg/cli/display/system_renderer.go new file mode 100644 index 00000000000..d234927efbc --- /dev/null +++ b/cli/pkg/cli/display/system_renderer.go @@ -0,0 +1,269 @@ +package display + +import ( + "fmt" + "strings" + + "github.com/cline/cli/pkg/cli/clerror" +) + +// ErrorSeverity represents the severity level of an error +type ErrorSeverity string + +const ( + SeverityCritical ErrorSeverity = "critical" + SeverityWarning ErrorSeverity = "warning" + SeverityInfo ErrorSeverity = "info" +) + +// SystemMessageRenderer handles rendering of system messages (errors, warnings, info) +type SystemMessageRenderer struct { + renderer *Renderer + mdRenderer *MarkdownRenderer + outputFormat string +} + +// NewSystemMessageRenderer creates a new system message renderer +func NewSystemMessageRenderer(renderer *Renderer, mdRenderer *MarkdownRenderer, outputFormat string) *SystemMessageRenderer { + return &SystemMessageRenderer{ + renderer: renderer, + mdRenderer: mdRenderer, + outputFormat: outputFormat, + } +} + +// RenderError renders a beautiful error message with optional details +func (sr *SystemMessageRenderer) RenderError(severity ErrorSeverity, title, body string, details map[string]string) error { + var colorMarkdown string + + switch severity { + case SeverityCritical: + colorMarkdown = "**[ERROR]**" + case SeverityWarning: + colorMarkdown = "**[WARNING]**" + case SeverityInfo: + colorMarkdown = "**[INFO]**" + } + + // Build the error message in markdown + var parts []string + + // Header + header := fmt.Sprintf("### %s %s", colorMarkdown, title) + parts = append(parts, header) + + // Body + if body != "" { + parts = append(parts, "", body) + } + + // Details + if len(details) > 0 { + parts = append(parts, "", "**Details:**") + for key, value := range details { + parts = append(parts, fmt.Sprintf("- %s: `%s`", key, value)) + } + } + + markdown := strings.Join(parts, "\n") + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + + return nil +} + +// RenderBalanceError renders a special balance/credits error with helpful info +func (sr *SystemMessageRenderer) RenderBalanceError(err *clerror.ClineError) error { + var parts []string + + // Header + parts = append(parts, "### **[ERROR]** Credit Limit Reached") + parts = append(parts, "") + + // Message - prefer detail message from error.details, fallback to main message + message := err.Message + if detailMsg := err.GetDetailMessage(); detailMsg != "" { + message = detailMsg + } + parts = append(parts, message) + parts = append(parts, "") + + // Account Balance section + parts = append(parts, "**Account Balance:**") + + // Current balance + if balance := err.GetCurrentBalance(); balance != nil { + parts = append(parts, fmt.Sprintf("- Current Balance: **$%.2f**", *balance)) + } + + // Total spent + if spent := err.GetTotalSpent(); spent != nil { + parts = append(parts, fmt.Sprintf("- Total Spent: $%.2f", *spent)) + } + + // Promotions applied + if promos := err.GetTotalPromotions(); promos != nil { + parts = append(parts, fmt.Sprintf("- Promotions Applied: $%.2f", *promos)) + } + + parts = append(parts, "") + + // Buy credits link + if url := err.GetBuyCreditsURL(); url != "" { + parts = append(parts, fmt.Sprintf("**→ Buy credits:** %s", url)) + } else { + // Fallback - show both personal and org URLs + parts = append(parts, "**→ Buy credits:**") + parts = append(parts, " - Personal: https://app.cline.bot/dashboard/account?tab=credits") + parts = append(parts, " - Organization: https://app.cline.bot/dashboard/organization?tab=credits") + } + + // Request ID (less prominent at the end) + if err.RequestID != "" { + parts = append(parts, "") + parts = append(parts, fmt.Sprintf("*Request ID: %s*", err.RequestID)) + } + + markdown := strings.Join(parts, "\n") + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + + return nil +} + +// RenderAuthError renders an authentication error with helpful guidance +func (sr *SystemMessageRenderer) RenderAuthError(err *clerror.ClineError) error { + var parts []string + + // Header + parts = append(parts, "### **[ERROR]** Authentication Failed") + parts = append(parts, "") + + // Message - prefer detail message if available + message := err.Message + if detailMsg := err.GetDetailMessage(); detailMsg != "" { + message = detailMsg + } + parts = append(parts, message) + parts = append(parts, "") + + // Guidance + parts = append(parts, "**Next Steps:**") + parts = append(parts, "- Check your API key configuration") + parts = append(parts, "- Run `cline auth` to authenticate") + parts = append(parts, "- Verify your account status at https://app.cline.bot") + + // Request ID + if err.RequestID != "" { + parts = append(parts, "") + parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID)) + } + + markdown := strings.Join(parts, "\n") + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + + return nil +} + +// RenderRateLimitError renders a rate limit error with request ID +func (sr *SystemMessageRenderer) RenderRateLimitError(err *clerror.ClineError) error { + var parts []string + + // Header + parts = append(parts, "### **[WARNING]** Rate Limit Reached") + parts = append(parts, "") + + // Message - prefer detail message if available + message := err.Message + if detailMsg := err.GetDetailMessage(); detailMsg != "" { + message = detailMsg + } + parts = append(parts, message) + parts = append(parts, "") + + // Guidance + parts = append(parts, "The API will automatically retry this request.") + + // Request ID + if err.RequestID != "" { + parts = append(parts, "") + parts = append(parts, fmt.Sprintf("*Request ID: `%s`*", err.RequestID)) + } + + markdown := strings.Join(parts, "\n") + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + + return nil +} + +// RenderAPIError renders a generic API error with all available details +func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error { + var parts []string + + // Header + parts = append(parts, "### **[ERROR]** API Request Failed") + parts = append(parts, "") + + // Message - prefer detail message if available + message := err.Message + if detailMsg := err.GetDetailMessage(); detailMsg != "" { + message = detailMsg + } + parts = append(parts, message) + + // Details + var details []string + if err.RequestID != "" { + details = append(details, fmt.Sprintf("- Request ID: `%s`", err.RequestID)) + } + if code := err.GetCodeString(); code != "" { + details = append(details, fmt.Sprintf("- Error Code: `%s`", code)) + } + if err.Status > 0 { + details = append(details, fmt.Sprintf("- HTTP Status: `%d`", err.Status)) + } + if err.ModelID != "" { + details = append(details, fmt.Sprintf("- Model: `%s`", err.ModelID)) + } + if err.ProviderID != "" { + details = append(details, fmt.Sprintf("- Provider: `%s`", err.ProviderID)) + } + + if len(details) > 0 { + parts = append(parts, "") + parts = append(parts, "**Details:**") + parts = append(parts, strings.Join(details, "\n")) + } + + markdown := strings.Join(parts, "\n") + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + + return nil +} + +// RenderWarning renders a warning message +func (sr *SystemMessageRenderer) RenderWarning(title, message string) error { + markdown := fmt.Sprintf("### **[WARNING]** %s\n\n%s", title, message) + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + return nil +} + +// RenderInfo renders an info message +func (sr *SystemMessageRenderer) RenderInfo(title, message string) error { + markdown := fmt.Sprintf("### **[INFO]** %s\n\n%s", title, message) + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n", rendered) + return nil +} + +// RenderCheckpoint renders a checkpoint creation message +func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error { + markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id) + rendered := sr.renderer.RenderMarkdown(markdown) + fmt.Printf(rendered) + return nil +} diff --git a/cli/pkg/cli/display/tool_renderer.go b/cli/pkg/cli/display/tool_renderer.go new file mode 100644 index 00000000000..e4b10237340 --- /dev/null +++ b/cli/pkg/cli/display/tool_renderer.go @@ -0,0 +1,446 @@ +package display + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/cline/cli/pkg/cli/types" +) + +// ToolRenderer provides unified rendering for tool and command messages +type ToolRenderer struct { + mdRenderer *MarkdownRenderer + outputFormat string +} + +// NewToolRenderer creates a new tool renderer +func NewToolRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *ToolRenderer { + return &ToolRenderer{ + mdRenderer: mdRenderer, + outputFormat: outputFormat, + } +} + +// RenderToolApprovalRequest renders a tool approval request ("Cline wants to...") +func (tr *ToolRenderer) RenderToolApprovalRequest(tool *types.ToolMessage) string { + var output strings.Builder + + // Generate header + header := tr.generateToolHeader(tool, "wants to") + rendered := tr.renderMarkdown(header) + output.WriteString(rendered) + output.WriteString("\n") + + // Add content preview for relevant tools + contentPreview := tr.GenerateToolContentPreview(tool) + if contentPreview != "" { + output.WriteString("\n") + output.WriteString(contentPreview) + } + + return output.String() +} + +// RenderToolExecution renders a completed tool execution ("Cline is ...ing") +func (tr *ToolRenderer) RenderToolExecution(tool *types.ToolMessage) string { + var output strings.Builder + + // Generate header + header := tr.generateToolHeader(tool, "is") + rendered := tr.renderMarkdown(header) + output.WriteString("\n") + output.WriteString(rendered) + output.WriteString("\n") + + // Add content body for relevant tools + contentBody := tr.GenerateToolContentBody(tool) + if contentBody != "" { + output.WriteString("\n") + output.WriteString(contentBody) + output.WriteString("\n") + } + + return output.String() +} + +// RenderToolExecutionHeader renders just the header for streaming (no body) +func (tr *ToolRenderer) RenderToolExecutionHeader(tool *types.ToolMessage) string { + header := tr.generateToolHeader(tool, "is") + return header +} + +// RenderToolApprovalHeader renders just the header for approval requests (no body) +func (tr *ToolRenderer) RenderToolApprovalHeader(tool *types.ToolMessage) string { + header := tr.generateToolHeader(tool, "wants to") + return header +} + +// generateToolHeader generates the markdown header for a tool message +func (tr *ToolRenderer) generateToolHeader(tool *types.ToolMessage, verbTense string) string { + var verb string + var action string + + switch tool.Tool { + case string(types.ToolTypeEditedExistingFile): + if verbTense == "wants to" { + action = "wants to edit" + } else { + action = "is editing" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeNewFileCreated): + if verbTense == "wants to" { + action = "wants to write" + } else { + action = "is writing" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeReadFile): + if verbTense == "wants to" { + action = "wants to read" + } else { + action = "is reading" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeListFilesTopLevel): + if verbTense == "wants to" { + action = "wants to list files in" + } else { + action = "is listing files in" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeListFilesRecursive): + if verbTense == "wants to" { + action = "wants to recursively list files in" + } else { + action = "is recursively listing files in" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeSearchFiles): + if tool.Regex != "" && tool.Path != "" { + if verbTense == "wants to" { + action = "wants to search for" + } else { + action = "is searching for" + } + return fmt.Sprintf("### Cline %s `%s` in `%s`", action, tool.Regex, tool.Path) + } else if tool.Regex != "" { + if verbTense == "wants to" { + action = "wants to search for" + } else { + action = "is searching for" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Regex) + } else { + if verbTense == "wants to" { + return "### Cline wants to search files" + } else { + return "### Cline is searching files" + } + } + + case string(types.ToolTypeWebFetch): + if verbTense == "wants to" { + action = "wants to fetch" + } else { + action = "is fetching" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeListCodeDefinitionNames): + if verbTense == "wants to" { + action = "wants to list code definitions in" + } else { + action = "is listing code definitions in" + } + return fmt.Sprintf("### Cline %s `%s`", action, tool.Path) + + case string(types.ToolTypeSummarizeTask): + if verbTense == "wants to" { + return "### Cline wants to condense the conversation" + } else { + return "### Cline condensed the conversation" + } + + default: + if verbTense == "wants to" { + verb = "wants to use" + } else { + verb = "is using" + } + return fmt.Sprintf("### Cline %s tool: %s", verb, tool.Tool) + } +} + +// GenerateToolContentPreview generates content preview for approval requests +func (tr *ToolRenderer) GenerateToolContentPreview(tool *types.ToolMessage) string { + if tool.Content == "" { + return "" + } + + switch tool.Tool { + case string(types.ToolTypeEditedExistingFile): + // Show diff for edits + diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content) + return tr.renderMarkdown(diffMarkdown) + + case string(types.ToolTypeNewFileCreated): + // Show content preview for new files (truncated) + preview := strings.TrimSpace(tool.Content) + if len(preview) > 500 { + preview = preview[:500] + "..." + } + previewMd := fmt.Sprintf("```\n%s\n```", preview) + return tr.renderMarkdown(previewMd) + + case string(types.ToolTypeReadFile), string(types.ToolTypeWebFetch): + // No preview for read/fetch operations + return "" + + default: + // For other tools, show truncated content if available + preview := strings.TrimSpace(tool.Content) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + if preview != "" { + return fmt.Sprintf("Preview: %s", preview) + } + return "" + } +} + +// GenerateToolContentBody generates full content for completed executions +func (tr *ToolRenderer) GenerateToolContentBody(tool *types.ToolMessage) string { + if tool.Content == "" { + return "" + } + + // Use enhanced tool result parser for supported tools + toolParser := NewToolResultParser(tr.mdRenderer) + + switch tool.Tool { + case string(types.ToolTypeReadFile): + // readFile: show header only, no body + return "" + + case string(types.ToolTypeListFilesTopLevel), + string(types.ToolTypeListFilesRecursive), + string(types.ToolTypeListCodeDefinitionNames), + string(types.ToolTypeSearchFiles), + string(types.ToolTypeWebFetch): + // Use parser for structured output + preview := toolParser.ParseToolResult(tool) + return tr.renderMarkdown(preview) + + case string(types.ToolTypeEditedExistingFile): + // Show the diff + diffMarkdown := fmt.Sprintf("```diff\n%s\n```", tool.Content) + return tr.renderMarkdown(diffMarkdown) + + case string(types.ToolTypeNewFileCreated): + // Show file content preview + preview := strings.TrimSpace(tool.Content) + if len(preview) > 1000 { + preview = preview[:1000] + "..." + } + contentMd := fmt.Sprintf("```\n%s\n```", preview) + return tr.renderMarkdown(contentMd) + + default: + // For unknown tools, show content as-is + if len(tool.Content) > 500 { + return tool.Content[:500] + "..." + } + return tool.Content + } +} + +// RenderCommandApprovalRequest renders a command approval request +func (tr *ToolRenderer) RenderCommandApprovalRequest(command string, autoApprovalConflict bool) string { + var output strings.Builder + + // Clean command + command = strings.TrimSpace(command) + if strings.HasSuffix(command, "REQ_APP") { + command = strings.TrimSuffix(command, "REQ_APP") + command = strings.TrimSpace(command) + autoApprovalConflict = true + } + + // Generate header + header := fmt.Sprintf("### Cline wants to run `%s`", command) + rendered := tr.renderMarkdown(header) + output.WriteString(rendered) + output.WriteString("\n") + + // Show command in code block + cmdBlock := fmt.Sprintf("```shell\n%s\n```", command) + cmdRendered := tr.renderMarkdown(cmdBlock) + output.WriteString("\n") + output.WriteString(cmdRendered) + + // Add warning if needed + if autoApprovalConflict { + output.WriteString("\nWARNING: The model has determined this command requires explicit approval.\n") + } + + return output.String() +} + +// RenderCommandExecution renders a command execution announcement +func (tr *ToolRenderer) RenderCommandExecution(command string) string { + command = strings.TrimSpace(command) + header := fmt.Sprintf("### Cline is running `%s`", command) + rendered := tr.renderMarkdown(header) + return "\n" + rendered + "\n" +} + +// RenderCommandOutput renders command output +func (tr *ToolRenderer) RenderCommandOutput(output string) string { + var result strings.Builder + + header := "### Terminal output" + rendered := tr.renderMarkdown(header) + result.WriteString("\n") + result.WriteString(rendered) + result.WriteString("\n\n") + + // Show output in code block + outputBlock := fmt.Sprintf("```\n%s\n```", strings.TrimSpace(output)) + outputRendered := tr.renderMarkdown(outputBlock) + result.WriteString(outputRendered) + result.WriteString("\n") + + return result.String() +} + +// RenderUserResponse renders user approval/rejection feedback +func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string { + var symbol, status string + + if approved { + symbol = "✓" + status = "Approved" + } else { + symbol = "✗" + status = "Rejected" + } + + if feedback != "" { + return fmt.Sprintf("%s %s with feedback: %s\n", symbol, status, feedback) + } + return fmt.Sprintf("%s %s\n", symbol, status) +} + +// renderMarkdown renders markdown if not in plain mode and in a TTY +func (tr *ToolRenderer) renderMarkdown(markdown string) string { + // Skip markdown rendering if plain mode or not in TTY + if tr.outputFormat == "plain" || !isTTY() { + return markdown + } + + if tr.mdRenderer == nil { + return markdown + } + + rendered, err := tr.mdRenderer.Render(markdown) + if err != nil { + return markdown + } + + return rendered +} + +// GenerateAskFollowupHeader generates the header for followup questions +func (tr *ToolRenderer) GenerateAskFollowupHeader() string { + return "### Cline has a question\n" +} + +// GenerateAskFollowupBody generates the body content for followup questions +func (tr *ToolRenderer) GenerateAskFollowupBody(messageText string) string { + var question string + var options []string + + // Try to parse as JSON + var askData types.AskData + if err := json.Unmarshal([]byte(messageText), &askData); err == nil { + question = askData.Question + options = askData.Options + } else { + question = messageText + } + + if question == "" { + return "" + } + + // Build the body + var body strings.Builder + + // Render the question + rendered := tr.renderMarkdown(question) + body.WriteString(rendered) + + // Add options if available + if len(options) > 0 { + body.WriteString("\n\nOptions:\n") + for i, option := range options { + body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option)) + } + } + + return body.String() +} + +// GeneratePlanModeRespondHeader generates the header for plan mode responses +func (tr *ToolRenderer) GeneratePlanModeRespondHeader() string { + return "### Cline has a plan\n" +} + +// GeneratePlanModeRespondBody generates the body content for plan mode responses +func (tr *ToolRenderer) GeneratePlanModeRespondBody(messageText string) string { + var response string + var options []string + + // Try to parse as JSON + type PlanModeResponse struct { + Response string `json:"response"` + Options []string `json:"options,omitempty"` + } + + var planData PlanModeResponse + if err := json.Unmarshal([]byte(messageText), &planData); err == nil { + response = planData.Response + options = planData.Options + } else { + response = messageText + } + + if response == "" { + return "" + } + + // Build the body + var body strings.Builder + + // Render the response + rendered := tr.renderMarkdown(response) + body.WriteString(rendered) + + // Add options if available + if len(options) > 0 { + body.WriteString("\n\nOptions:\n") + for i, option := range options { + body.WriteString(fmt.Sprintf("%d. %s\n", i+1, option)) + } + } + + return body.String() +} diff --git a/cli/pkg/cli/display/tool_result_parser.go b/cli/pkg/cli/display/tool_result_parser.go new file mode 100644 index 00000000000..cf652a21c14 --- /dev/null +++ b/cli/pkg/cli/display/tool_result_parser.go @@ -0,0 +1,371 @@ +package display + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/cline/cli/pkg/cli/types" +) + +// ToolResultParser handles parsing and formatting tool results for display +type ToolResultParser struct { + maxPreviewLines int + maxPreviewChars int + mdRenderer *MarkdownRenderer +} + +// NewToolResultParser creates a new tool result parser +func NewToolResultParser(mdRenderer *MarkdownRenderer) *ToolResultParser { + return &ToolResultParser{ + maxPreviewLines: 15, + maxPreviewChars: 500, + mdRenderer: mdRenderer, + } +} + +// ParseReadFile formats readFile tool results with smart preview +func (p *ToolResultParser) ParseReadFile(content, path string) string { + lines := strings.Split(content, "\n") + totalLines := len(lines) + + // Get file extension for syntax highlighting + ext := filepath.Ext(path) + lang := p.detectLanguage(ext) + + var preview strings.Builder + + // Show header with line count + preview.WriteString(fmt.Sprintf("*%d lines*\n\n", totalLines)) + + // Show preview of content + previewLines := p.maxPreviewLines + if totalLines < previewLines { + previewLines = totalLines + } + + preview.WriteString(fmt.Sprintf("```%s\n", lang)) + for i := 0; i < previewLines; i++ { + preview.WriteString(lines[i]) + preview.WriteString("\n") + } + + if totalLines > previewLines { + preview.WriteString("...\n") + } + preview.WriteString("```\n") + + if totalLines > previewLines { + preview.WriteString(fmt.Sprintf("\n*[Content truncated - showing %d of %d lines]*", previewLines, totalLines)) + } + + return preview.String() +} + +// ParseListFiles formats listFiles tool results with directory tree +func (p *ToolResultParser) ParseListFiles(content, path string) string { + if content == "" || content == "No files found." { + return "*No files found*" + } + + lines := strings.Split(strings.TrimSpace(content), "\n") + + // Check for truncation message + var truncationMsg string + lastLine := lines[len(lines)-1] + if strings.Contains(lastLine, "File list truncated") { + truncationMsg = lastLine + lines = lines[:len(lines)-1] + } + + totalFiles := len(lines) + + var result strings.Builder + result.WriteString(fmt.Sprintf("*%d %s*\n\n", totalFiles, p.pluralize(totalFiles, "file", "files"))) + + // Show up to 20 files in tree format + maxShow := 20 + if totalFiles < maxShow { + maxShow = totalFiles + } + + result.WriteString("```\n") + for i := 0; i < maxShow; i++ { + line := lines[i] + // Add tree characters for better visualization + if strings.HasPrefix(line, "🔒 ") { + result.WriteString("├── 🔒 ") + result.WriteString(strings.TrimPrefix(line, "🔒 ")) + } else { + result.WriteString("├── ") + result.WriteString(line) + } + result.WriteString("\n") + } + + if totalFiles > maxShow { + result.WriteString("└── ...\n") + } + result.WriteString("```\n") + + if totalFiles > maxShow { + result.WriteString(fmt.Sprintf("\n*[Showing %d of %d files]*", maxShow, totalFiles)) + } + + if truncationMsg != "" { + result.WriteString(fmt.Sprintf("\n\n*%s*", truncationMsg)) + } + + return result.String() +} + +// ParseSearchFiles formats searchFiles tool results with context +func (p *ToolResultParser) ParseSearchFiles(content string) string { + if content == "" || content == "Found 0 results." { + return "*No results found*" + } + + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return "*No results found*" + } + + // Extract result count from first line + firstLine := lines[0] + + var result strings.Builder + result.WriteString(fmt.Sprintf("*%s*\n\n", firstLine)) + + // Parse and group results by file + var currentFile string + var fileResults []string + filesShown := 0 + maxFiles := 5 + matchesShown := 0 + maxMatches := 15 + + for i := 1; i < len(lines) && filesShown < maxFiles && matchesShown < maxMatches; i++ { + line := lines[i] + + if line == "" { + continue + } + + // Check if this is a file path (doesn't start with whitespace or line number) + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && strings.Contains(line, ":") { + // Save previous file results + if currentFile != "" && len(fileResults) > 0 { + result.WriteString(p.formatFileMatches(currentFile, fileResults)) + filesShown++ + } + + currentFile = line + fileResults = []string{} + } else if currentFile != "" { + // This is a match line + fileResults = append(fileResults, strings.TrimSpace(line)) + matchesShown++ + } + } + + // Add last file's results + if currentFile != "" && len(fileResults) > 0 && filesShown < maxFiles { + result.WriteString(p.formatFileMatches(currentFile, fileResults)) + filesShown++ + } + + // Add truncation notice + totalMatches := strings.Count(content, "\n") - 1 // Rough estimate + if matchesShown < totalMatches { + result.WriteString(fmt.Sprintf("\n*[Showing %d results - see full output for all matches]*", matchesShown)) + } + + return result.String() +} + +// formatFileMatches formats matches for a single file +func (p *ToolResultParser) formatFileMatches(file string, matches []string) string { + var result strings.Builder + + // Parse file path and extension for syntax highlighting + ext := filepath.Ext(file) + lang := p.detectLanguage(ext) + + result.WriteString(fmt.Sprintf("**%s** (%d %s)\n", file, len(matches), p.pluralize(len(matches), "match", "matches"))) + result.WriteString(fmt.Sprintf("```%s\n", lang)) + + maxMatches := 5 + for i, match := range matches { + if i >= maxMatches { + result.WriteString("...\n") + break + } + result.WriteString(match) + result.WriteString("\n") + } + + result.WriteString("```\n\n") + + return result.String() +} + +// ParseCodeDefinitions formats listCodeDefinitionNames tool results +func (p *ToolResultParser) ParseCodeDefinitions(content string) string { + if content == "" || content == "No source code definitions found." { + return "*No code definitions found*" + } + + // Return the full content as-is + return content +} + +// ParseWebFetch formats webFetch tool results with content preview +func (p *ToolResultParser) ParseWebFetch(content, url string) string { + if content == "" { + return fmt.Sprintf("*Fetched content from %s (empty response)*", url) + } + + lines := strings.Split(content, "\n") + + var result strings.Builder + + // Try to extract title + var title string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "##") { + title = strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + break + } + } + + if title != "" { + result.WriteString(fmt.Sprintf("**Title:** %s\n\n", title)) + } + + // Show preview of content + result.WriteString("**Preview:**\n") + + charCount := 0 + maxChars := 500 + previewLines := []string{} + + for _, line := range lines { + // Skip markdown headers + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if charCount+len(trimmed) > maxChars { + break + } + + previewLines = append(previewLines, trimmed) + charCount += len(trimmed) + } + + result.WriteString(strings.Join(previewLines, " ")) + result.WriteString("...\n\n") + + // Extract sections + sections := []string{} + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "##") { + section := strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) + sections = append(sections, section) + if len(sections) >= 5 { + break + } + } + } + + if len(sections) > 0 { + result.WriteString("**Sections Found:**\n") + for _, section := range sections { + result.WriteString(fmt.Sprintf("- %s\n", section)) + } + result.WriteString("\n") + } + + // Word count estimate + wordCount := len(strings.Fields(content)) + result.WriteString(fmt.Sprintf("*[Full content: ~%s]*", p.formatWordCount(wordCount))) + + return result.String() +} + +// detectLanguage returns syntax highlighting language based on file extension +func (p *ToolResultParser) detectLanguage(ext string) string { + langMap := map[string]string{ + ".ts": "typescript", + ".tsx": "tsx", + ".js": "javascript", + ".jsx": "jsx", + ".go": "go", + ".py": "python", + ".rb": "ruby", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".cs": "csharp", + ".php": "php", + ".sh": "bash", + ".bash": "bash", + ".zsh": "bash", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".xml": "xml", + ".html": "html", + ".css": "css", + ".scss": "scss", + ".md": "markdown", + ".sql": "sql", + ".rs": "rust", + } + + if lang, ok := langMap[ext]; ok { + return lang + } + return "" +} + +// pluralize returns the correct plural form +func (p *ToolResultParser) pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} + +// formatWordCount formats word count with appropriate unit +func (p *ToolResultParser) formatWordCount(count int) string { + if count < 1000 { + return fmt.Sprintf("%d words", count) + } + return fmt.Sprintf("%.1fk words", float64(count)/1000.0) +} + +// ParseToolResult is the main entry point for parsing tool results +func (p *ToolResultParser) ParseToolResult(tool *types.ToolMessage) string { + switch tool.Tool { + case "readFile": + return p.ParseReadFile(tool.Content, tool.Path) + case "listFilesTopLevel", "listFilesRecursive": + return p.ParseListFiles(tool.Content, tool.Path) + case "searchFiles": + return p.ParseSearchFiles(tool.Content) + case "listCodeDefinitionNames": + return p.ParseCodeDefinitions(tool.Content) + case "webFetch": + return p.ParseWebFetch(tool.Content, tool.Path) + default: + return tool.Content + } +} diff --git a/cli/pkg/cli/display/typewriter.go b/cli/pkg/cli/display/typewriter.go index 1a1071f606b..ba3ca3b87fe 100644 --- a/cli/pkg/cli/display/typewriter.go +++ b/cli/pkg/cli/display/typewriter.go @@ -160,11 +160,8 @@ func (tp *TypewriterPrinter) SetSpeed(multiplier float64) { tp.config.PauseDelay = time.Duration(float64(150*time.Millisecond) / multiplier) } -// PrintMessageLine prints a complete message line with typewriter effect -func (tp *TypewriterPrinter) PrintMessageLine(timestamp, prefix, text string) { - // Print the timestamp and prefix with 10-char padding - tp.PrintfInstant("[%s] %-10s: ", timestamp, prefix) - // Print the message text with typewriter effect +func (tp *TypewriterPrinter) PrintMessageLine(prefix, text string) { + tp.PrintfInstant("%s: ", prefix) tp.Println(text) } @@ -193,9 +190,8 @@ func TypewriterPrintfLn(format string, args ...interface{}) { globalTypewriter.PrintfLn(format, args...) } -// TypewriterPrintMessageLine prints a message line with typewriter effect using the global instance -func TypewriterPrintMessageLine(timestamp, prefix, text string) { - globalTypewriter.PrintMessageLine(timestamp, prefix, text) +func TypewriterPrintMessageLine(prefix, text string) { + globalTypewriter.PrintMessageLine(prefix, text) } // SetGlobalTypewriterEnabled enables or disables the global typewriter effect diff --git a/cli/pkg/cli/doctor.go b/cli/pkg/cli/doctor.go new file mode 100644 index 00000000000..c022e26fe8e --- /dev/null +++ b/cli/pkg/cli/doctor.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + + "github.com/cline/cli/pkg/cli/display" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/terminal" + "github.com/cline/cli/pkg/cli/updater" + "github.com/spf13/cobra" +) + +// NewDoctorCommand creates the doctor command +func NewDoctorCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "doctor", + Aliases: []string{"d"}, + Short: "Check system health and diagnose problems", + Long: `Check the health of your Cline CLI installation and diagnose problems. + +Currently this command performs the following checks and fixes: + +Terminal Configuration: + - Detects your terminal emulator (VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty) + - Configures shift+enter to insert newlines in multiline input + - Creates backups before modifying configuration files + - Supported terminals: VS Code, Cursor, Ghostty, Kitty, WezTerm, Alacritty + - iTerm2 works by default, Terminal.app requires manual setup + +CLI Updates: + - Checks npm registry for the latest version + - Automatically installs updates via npm if available + - Respects NO_AUTO_UPDATE environment variable + - Skipped in CI environments + +Note: Future versions will include additional health checks for Node.js version, +npm availability, Cline Core connectivity, database integrity, and more.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDoctorChecks() + }, + } + + return cmd +} + +// runDoctorChecks performs all doctor diagnostics and configuration +func runDoctorChecks() error { + renderer := display.NewRenderer(global.Config.OutputFormat) + + fmt.Printf("\n%s\n\n", renderer.Bold("Cline Doctor - System Health Check")) + + // Configure terminal keybindings (terminal.go prints its own status) + fmt.Printf("%s\n\n", renderer.Dim("━━━ Terminal Configuration ━━━")) + terminal.SetupKeyboardSync() + + // Check for updates (updater.go prints its own status) + fmt.Printf("\n%s\n\n", renderer.Dim("━━━ CLI Updates ━━━")) + updater.CheckAndUpdateSync(global.Config.Verbose, true) + + // Summary + fmt.Printf("\n%s\n", renderer.Dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + fmt.Printf("\n%s\n\n", renderer.SuccessWithCheckmark("Health check complete")) + + return nil +} diff --git a/cli/pkg/cli/global/cline-clients.go b/cli/pkg/cli/global/cline-clients.go index 432df2d6f23..d9edef88c2d 100644 --- a/cli/pkg/cli/global/cline-clients.go +++ b/cli/pkg/cli/global/cline-clients.go @@ -5,9 +5,13 @@ import ( "fmt" "os" "os/exec" + "path" + "path/filepath" + "syscall" "time" "github.com/cline/cli/pkg/common" + "github.com/cline/grpc-go/cline" ) // ClineClients manages Cline instances using the new registry system @@ -39,7 +43,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan return nil, fmt.Errorf("failed to find available ports: %w", err) } - fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort) + if Config.Verbose { + fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort) + } // Start cline-host first hostCmd, err := startClineHost(hostPort, corePort) @@ -58,7 +64,9 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan } fullAddress := fmt.Sprintf("localhost:%d", corePort) - fmt.Println("Waiting for services to start and self-register in SQLite...") + if Config.Verbose { + fmt.Println("Waiting for services to start and self-register in SQLite...") + } // Use RetryOperation to wait for instance to be ready var instance *common.CoreInstanceInfo @@ -92,11 +100,22 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan return nil, fmt.Errorf("failed to start instance: %w", err) } - fmt.Println("✅ Services started and registered successfully!") - fmt.Printf(" Address: %s\n", instance.Address) - fmt.Printf(" Core Port: %d\n", instance.CorePort()) - fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort()) - fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid) + if Config.Verbose { + fmt.Println("Services started and registered successfully!") + fmt.Printf(" Address: %s\n", instance.Address) + fmt.Printf(" Core Port: %d\n", instance.CorePort()) + fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort()) + fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid) + } + + // If this is the first instance, set it as default + instances := c.registry.ListInstances() + if err := c.registry.EnsureDefaultInstance(instances); err != nil { + if Config.Verbose { + fmt.Printf("Warning: Failed to set default instance: %v\n", err) + } + } + return instance, nil } @@ -111,7 +130,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) return nil, fmt.Errorf("port %d is already in use by another Cline instance", corePort) } - fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort) + if Config.Verbose { + fmt.Printf("Starting new Cline instance on ports %d (core) and %d (host bridge)\n", corePort, hostPort) + } // Start cline-host first hostCmd, err := startClineHost(hostPort, corePort) @@ -130,7 +151,9 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) } fullAddress := fmt.Sprintf("localhost:%d", corePort) - fmt.Println("Waiting for services to start and self-register in SQLite...") + if Config.Verbose { + fmt.Println("Waiting for services to start and self-register in SQLite...") + } // Use RetryOperation to wait for instance to be ready var instance *common.CoreInstanceInfo @@ -164,11 +187,22 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) return nil, fmt.Errorf("failed to start instance at port %d: %w", corePort, err) } - fmt.Println("✅ Services started and registered successfully!") - fmt.Printf(" Address: %s\n", instance.Address) - fmt.Printf(" Core Port: %d\n", instance.CorePort()) - fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort()) - fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid) + if Config.Verbose { + fmt.Println("Services started and registered successfully!") + fmt.Printf(" Address: %s\n", instance.Address) + fmt.Printf(" Core Port: %d\n", instance.CorePort()) + fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort()) + fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid) + } + + // If this is the first instance, set it as default + instances := c.registry.ListInstances() + if err := c.registry.EnsureDefaultInstance(instances); err != nil { + if Config.Verbose { + fmt.Printf("Warning: Failed to set default instance: %v\n", err) + } + } + return instance, nil } @@ -209,66 +243,259 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri } func startClineHost(hostPort, corePort int) (*exec.Cmd, error) { - fmt.Printf("Starting cline-host on port %d\n", hostPort) + if Config.Verbose { + fmt.Printf("Starting cline-host on port %d\n", hostPort) + } + + // Get the directory where the cline binary is located + execPath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("failed to get executable path: %w", err) + } + binDir := path.Dir(execPath) + clineHostPath := path.Join(binDir, "cline-host") // Start the cline-host process - cmd := exec.Command("./cli/bin/cline-host", + cmd := exec.Command(clineHostPath, "--verbose", "--port", fmt.Sprintf("%d", hostPort)) + // Create logs directory in ~/.cline/logs + logsDir := path.Join(Config.ConfigPath, "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create logs directory: %w", err) + } + + // Create timestamped log file + timestamp := time.Now().Format("2006-01-02-15-04-05") + logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort) + logFilePath := path.Join(logsDir, logFileName) + logFile, err := os.Create(logFilePath) + if err != nil { + return nil, fmt.Errorf("failed to create log file: %w", err) + } + + // Redirect stdout and stderr to log file + cmd.Stdout = logFile + cmd.Stderr = logFile + + // Put the child process in a new process group so Ctrl+C doesn't kill it + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + if err := cmd.Start(); err != nil { + logFile.Close() return nil, fmt.Errorf("failed to start cline-host: %w", err) } - fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid) + if Config.Verbose { + fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid) + fmt.Printf("Logging cline-host output to: %s\n", logFilePath) + } return cmd, nil } +// 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) { - fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort) + if Config.Verbose { + fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort) + } - // Create port-tagged log file in OS temp directory with full address - logFileName := fmt.Sprintf("cline-core-debug-localhost-%d.log", corePort) - logFilePath := fmt.Sprintf("%s/%s", os.TempDir(), logFileName) + // 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 instead of CLINE_DIR env var - args := []string{"cline-core.js", + // Start the cline-core process with --config flag using system node + args := []string{finalClineCorePath, "--port", fmt.Sprintf("%d", corePort), "--host-bridge-port", fmt.Sprintf("%d", hostPort), "--config", Config.ConfigPath} - fmt.Printf("DEBUG: Starting cline-core with command: node %v\n", args) - fmt.Printf("DEBUG: Working directory: ./dist-standalone\n") - fmt.Printf("DEBUG: Config path: %s\n", Config.ConfigPath) + if Config.Verbose { + fmt.Printf("Using system node\n") + } cmd := exec.Command("node", args...) - // Set working directory to dist-standalone (relative to project root) - cmd.Dir = "./dist-standalone" + // Set working directory to installation root + cmd.Dir = finalInstallDir // Redirect stdout and stderr to log file cmd.Stdout = logFile cmd.Stderr = logFile - // Set environment variables (removed CLINE_DIR) + // Put the child process in a new process group so Ctrl+C doesn't kill it + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + // Set environment variables with NODE_PATH for both real and fake node_modules + // The fake node_modules contains the vscode stub that can't be in the real node_modules env := os.Environ() + realNodeModules := path.Join(finalInstallDir, "node_modules") + fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules") + nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules) + env = append(env, + fmt.Sprintf("NODE_PATH=%s", nodePath), "GRPC_TRACE=all", "GRPC_VERBOSITY=DEBUG", "NODE_ENV=development", ) cmd.Env = env + + if Config.Verbose { + fmt.Printf("NODE_PATH set to: %s\n", nodePath) + } if err := cmd.Start(); err != nil { logFile.Close() return nil, fmt.Errorf("failed to start cline-core: %w", err) } - fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid) - fmt.Printf("Logging cline-core output to: %s\n", logFilePath) + if Config.Verbose { + fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid) + fmt.Printf("Logging cline-core output to: %s\n", logFilePath) + } return cmd, nil } diff --git a/cli/pkg/cli/global/global.go b/cli/pkg/cli/global/global.go index 98c6f165f8c..059ae990bcb 100644 --- a/cli/pkg/cli/global/global.go +++ b/cli/pkg/cli/global/global.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" + "github.com/charmbracelet/lipgloss" "github.com/cline/cli/pkg/common" "github.com/cline/grpc-go/client" + "github.com/muesli/termenv" ) type Port uint16 @@ -22,6 +24,15 @@ type GlobalConfig struct { var ( Config *GlobalConfig Clients *ClineClients + + // Version info - set at build time via ldflags + // Version is the Cline Core version (from root package.json) + Version = "dev" + // CliVersion is the CLI package version (from cli/package.json) + CliVersion = "dev" + Commit = "unknown" + Date = "unknown" + BuiltBy = "unknown" ) func InitializeGlobalConfig(cfg *GlobalConfig) error { @@ -38,6 +49,12 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error { return fmt.Errorf("failed to create config directory: %w", err) } + // Configure lipgloss color profile based on output format + if cfg.OutputFormat == "plain" { + lipgloss.SetColorProfile(termenv.Ascii) // NO COLOR mode + } + // Otherwise lipgloss auto-detects terminal capabilities (default behavior) + Config = cfg Clients = NewClineClients(cfg.ConfigPath) @@ -65,3 +82,32 @@ func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) { func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) { return Clients.GetRegistry().GetClient(ctx, address) } + +// EnsureDefaultInstance ensures a default instance exists +func EnsureDefaultInstance(ctx context.Context) error { + if Clients == nil { + return fmt.Errorf("global clients not initialized") + } + + registry := Clients.GetRegistry() + + // First, check if there are any instances already registered in SQLite + instances := registry.ListInstances() + + // Use the registry's EnsureDefaultInstance to auto-set first instance as default if needed + if err := registry.EnsureDefaultInstance(instances); err != nil { + return fmt.Errorf("failed to ensure default from existing instances: %w", err) + } + + // Now check if we have a default set + if registry.GetDefaultInstance() == "" { + // No instances exist, start a new one + // Note: StartNewInstance will automatically set it as default since it's the first instance + _, err := Clients.StartNewInstance(ctx) + if err != nil { + return fmt.Errorf("failed to start new default instance: %w", err) + } + } + + return nil +} \ No newline at end of file diff --git a/cli/pkg/cli/global/registry.go b/cli/pkg/cli/global/registry.go index ba3d654ca01..380d0c51cdd 100644 --- a/cli/pkg/cli/global/registry.go +++ b/cli/pkg/cli/global/registry.go @@ -110,6 +110,43 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli return nil, fmt.Errorf("no default instance configured") } + // Check if the default instance actually exists in the database + if r.lockManager != nil { + exists, err := r.lockManager.HasInstanceAtAddress(defaultAddr) + if err != nil { + // Database is unavailable - Return error instead of attempting cleanup + return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err) + } + + if !exists { + // Instance doesn't exist in database but config file references it + // This is a stale config - remove it and try to find another instance + settingsPath := filepath.Join(r.configPath, common.SETTINGS_SUBFOLDER, "settings", "cli-default-instance.json") + if removeErr := os.Remove(settingsPath); removeErr != nil && !os.IsNotExist(removeErr) { + fmt.Printf("Warning: Failed to remove stale default instance config: %v\n", removeErr) + } else { + fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr) + } + + // Try to find and set a new default instance + instances := r.ListInstances() + if len(instances) > 0 { + if err := r.EnsureDefaultInstance(instances); err != nil { + return nil, fmt.Errorf("failed to set new default instance: %w", err) + } + + // Retry with the new default + newDefaultAddr := r.GetDefaultInstance() + if newDefaultAddr != "" { + fmt.Printf("Set new default instance: %s\n", newDefaultAddr) + return r.GetClient(ctx, newDefaultAddr) + } + } + + return nil, fmt.Errorf("no default instance configured") + } + } + return r.GetClient(ctx, defaultAddr) } @@ -223,15 +260,15 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co instances := r.ListInstances() // 3. Ensure default is set if instances exist - if err := r.ensureDefaultInstance(instances); err != nil { + if err := r.EnsureDefaultInstance(instances); err != nil { fmt.Printf("Warning: Failed to ensure default instance: %v\n", err) } return instances, nil } -// ensureDefaultInstance ensures a default instance is set if instances exist but no default is configured -func (r *ClientRegistry) ensureDefaultInstance(instances []*common.CoreInstanceInfo) error { +// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured +func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error { currentDefault := r.GetDefaultInstance() // If we have no instances, clear any stale default and remove settings file diff --git a/cli/pkg/cli/handlers/ask_handlers.go b/cli/pkg/cli/handlers/ask_handlers.go index 511b45942a1..50aade1b5d6 100644 --- a/cli/pkg/cli/handlers/ask_handlers.go +++ b/cli/pkg/cli/handlers/ask_handlers.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/cline/cli/pkg/cli/clerror" + "github.com/cline/cli/pkg/cli/output" "github.com/cline/cli/pkg/cli/types" ) @@ -25,262 +27,263 @@ func (h *AskHandler) CanHandle(msg *types.ClineMessage) bool { return msg.IsAsk() } -// Handle processes ASK messages func (h *AskHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error { - timestamp := msg.GetTimestamp() + // Always display approval messages so user can see what they're approving + // The input handler will show the approval prompt form after the content is displayed switch msg.Ask { case string(types.AskTypeFollowup): - return h.handleFollowup(msg, dc, timestamp) + return h.handleFollowup(msg, dc) case string(types.AskTypePlanModeRespond): - return h.handlePlanModeRespond(msg, dc, timestamp) + return h.handlePlanModeRespond(msg, dc) case string(types.AskTypeCommand): - return h.handleCommand(msg, dc, timestamp) + return h.handleCommand(msg, dc) case string(types.AskTypeCommandOutput): - return h.handleCommandOutput(msg, dc, timestamp) + return h.handleCommandOutput(msg, dc) case string(types.AskTypeCompletionResult): - return h.handleCompletionResult(msg, dc, timestamp) + return h.handleCompletionResult(msg, dc) case string(types.AskTypeTool): - return h.handleTool(msg, dc, timestamp) + return h.handleTool(msg, dc) case string(types.AskTypeAPIReqFailed): - return h.handleAPIReqFailed(msg, dc, timestamp) + return h.handleAPIReqFailed(msg, dc) case string(types.AskTypeResumeTask): - return h.handleResumeTask(msg, dc, timestamp) + return h.handleResumeTask(msg, dc) case string(types.AskTypeResumeCompletedTask): - return h.handleResumeCompletedTask(msg, dc, timestamp) + return h.handleResumeCompletedTask(msg, dc) case string(types.AskTypeMistakeLimitReached): - return h.handleMistakeLimitReached(msg, dc, timestamp) + return h.handleMistakeLimitReached(msg, dc) case string(types.AskTypeAutoApprovalMaxReached): - return h.handleAutoApprovalMaxReached(msg, dc, timestamp) + return h.handleAutoApprovalMaxReached(msg, dc) case string(types.AskTypeBrowserActionLaunch): - return h.handleBrowserActionLaunch(msg, dc, timestamp) + return h.handleBrowserActionLaunch(msg, dc) case string(types.AskTypeUseMcpServer): - return h.handleUseMcpServer(msg, dc, timestamp) + return h.handleUseMcpServer(msg, dc) case string(types.AskTypeNewTask): - return h.handleNewTask(msg, dc, timestamp) + return h.handleNewTask(msg, dc) case string(types.AskTypeCondense): - return h.handleCondense(msg, dc, timestamp) + return h.handleCondense(msg, dc) case string(types.AskTypeReportBug): - return h.handleReportBug(msg, dc, timestamp) + return h.handleReportBug(msg, dc) default: - return h.handleDefault(msg, dc, timestamp) + return h.handleDefault(msg, dc) } } // handleFollowup handles followup questions -func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - var question string - var options []string - - var askData types.AskData - if err := json.Unmarshal([]byte(msg.Text), &askData); err == nil { - question = askData.Question - options = askData.Options - } else { - question = msg.Text - } +func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext) error { + body := dc.ToolRenderer.GenerateAskFollowupBody(msg.Text) - if question == "" { + if body == "" { return nil } - err := dc.Renderer.RenderMessage(timestamp, "QUESTION", question) - if err != nil { - return err - } - - // Display options if available - if len(options) > 0 { - fmt.Println("\nOptions:") - for i, option := range options { - fmt.Printf("%d. %s\n", i+1, option) - } + if dc.IsStreamingMode { + // In streaming mode, header was already shown by partial stream + // Just render the body content + output.Print(body) + } else { + // Non-streaming mode: render header + body together + header := dc.ToolRenderer.GenerateAskFollowupHeader() + rendered := dc.Renderer.RenderMarkdown(header) + output.Print("\n") + output.Print(rendered) + output.Print("\n") + output.Print(body) } return nil } // handlePlanModeRespond handles plan mode responses -func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - var response string - var options []string - - // Try to parse as JSON - type PlanModeResponse struct { - Response string `json:"response"` - Options []string `json:"options,omitempty"` - } - - var planData PlanModeResponse - if err := json.Unmarshal([]byte(msg.Text), &planData); err == nil { - response = planData.Response - options = planData.Options +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 { - response = msg.Text - } + // In non-streaming mode, render header + body together + header := dc.ToolRenderer.GeneratePlanModeRespondHeader() + body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text) - if response == "" { - return nil - } + if body == "" { + return nil + } - err := dc.Renderer.RenderMessage(timestamp, "ASST PLAN", response) - if err != nil { - return err - } + // Render header + rendered := dc.Renderer.RenderMarkdown(header) + output.Print("\n") + output.Print(rendered) + output.Print("\n") - // Display options if available - if len(options) > 0 { - fmt.Println("\nOptions:") - for i, option := range options { - fmt.Printf("%d. %s\n", i+1, option) - } + // Render body + output.Print(body) } return nil } +// showApprovalHint displays a hint in non-interactive mode about how to approve/deny +func (h *AskHandler) showApprovalHint(dc *DisplayContext) { + if !dc.IsInteractive { + output.Printf("\n%s\n", dc.Renderer.Dim("Cline is requesting approval to use this tool")) + output.Printf("%s\n", dc.Renderer.Dim("Use cline task send --approve or --deny to respond")) + } +} + // handleCommand handles command execution requests -func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } - command := msg.Text - - // Check if this command was flagged despite auto-approval settings turned on for safe commands - hasAutoApprovalConflict := strings.HasSuffix(command, "REQ_APP") - if hasAutoApprovalConflict { - command = strings.TrimSuffix(command, "REQ_APP") - } - - err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Cline wants to execute this command:") - if err != nil { - return fmt.Errorf("failed to render handleCommand: %w", err) - } - - fmt.Printf("\n```shell\n%s\n```\n", strings.TrimSpace(command)) + // Check if this command was flagged despite auto-approval settings + autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP") - if hasAutoApprovalConflict { - fmt.Printf("\nThe model has determined this command requires explicit approval.\n") - } else { - fmt.Printf("\nApproval required for this command.\n") - } + // Use unified ToolRenderer + rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict) + output.Print(rendered) + h.showApprovalHint(dc) return nil } // handleCommandOutput handles command output requests -func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } commandOutput := msg.Text - err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput)) - if err != nil { - return fmt.Errorf("failed to render handleCommandOutput: %w", err) - } + markdown := fmt.Sprintf("```\n%s\n```", commandOutput) + rendered := dc.Renderer.RenderMarkdown(markdown) - fmt.Printf("\nApprove to proceed while this command runs in the background.\n") + fmt.Printf("%s", rendered) return nil } // handleCompletionResult handles completion result requests -func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error { return nil } // handleTool handles tool execution requests -func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error { // Parse tool message var tool types.ToolMessage if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil { // Fallback to simple display - return dc.Renderer.RenderMessage(timestamp, "TOOL", msg.Text) + return dc.Renderer.RenderMessage("TOOL", msg.Text, true) } - return h.renderToolMessage(&tool, dc, timestamp) -} - -// renderToolMessage renders a tool message with appropriate formatting -func (h *AskHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error { - switch tool.Tool { - case string(types.ToolTypeEditedExistingFile): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to edit file: %s", tool.Path)) - case string(types.ToolTypeNewFileCreated): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to create file: %s", tool.Path)) - case string(types.ToolTypeReadFile): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to read file: %s", tool.Path)) - case string(types.ToolTypeListFilesTopLevel): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list files in: %s", tool.Path)) - case string(types.ToolTypeListFilesRecursive): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to recursively list files in: %s", tool.Path)) - case string(types.ToolTypeSearchFiles): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to search for '%s' in: %s", tool.Regex, tool.Path)) - case string(types.ToolTypeWebFetch): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to fetch URL: %s", tool.Path)) - case string(types.ToolTypeListCodeDefinitionNames): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to list code definitions for: %s", tool.Path)) - default: - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline wants to use tool: %s", tool.Tool)) - } - - // Skip content preview for readFile and webFetch tools - if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) { - return nil - } - - // Show content preview, truncating if necessary - preview := tool.Content - if preview != "" { - preview = strings.TrimSpace(tool.Content) - if len(preview) > 1000 { - preview = preview[:1000] + "..." + if dc.IsStreamingMode { + // In streaming mode, header was already shown by partial stream + // Just render the content preview + contentPreview := dc.ToolRenderer.GenerateToolContentPreview(&tool) + if contentPreview != "" { + output.Print("\n") + output.Print(contentPreview) } - - fmt.Printf("Preview: %s\n", preview) + } else { + // Non-streaming mode: render full approval (header + preview) + rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool) + output.Print(rendered) } - fmt.Printf("\nApproval required.\n") - + h.showApprovalHint(dc) return nil } // handleAPIReqFailed handles API request failures -func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text)) +func (h *AskHandler) handleAPIReqFailed(msg *types.ClineMessage, dc *DisplayContext) error { + // Try to parse as ClineError for better error display + clineErr, _ := clerror.ParseClineError(msg.Text) + if clineErr != nil { + if dc.SystemRenderer != nil { + // Render the error with system renderer + switch clineErr.GetErrorType() { + case clerror.ErrorTypeBalance: + dc.SystemRenderer.RenderBalanceError(clineErr) + case clerror.ErrorTypeAuth: + dc.SystemRenderer.RenderAuthError(clineErr) + case clerror.ErrorTypeRateLimit: + dc.SystemRenderer.RenderRateLimitError(clineErr) + default: + dc.SystemRenderer.RenderAPIError(clineErr) + } + return nil + } + // Fallback: render with basic renderer using parsed message + return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", clineErr.Message), true) + } + // Last resort: display raw text if parsing completely failed + return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("API Request Failed: %s. Approve to retry request.", msg.Text), true) } // handleResumeTask handles resume task requests -func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming interrupted task.") +func (h *AskHandler) handleResumeTask(msg *types.ClineMessage, dc *DisplayContext) error { + // Don't render - this is metadata only, user already knows they're resuming + return nil } // handleResumeCompletedTask handles resume completed task requests -func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Resuming completed task.") +func (h *AskHandler) handleResumeCompletedTask(msg *types.ClineMessage, dc *DisplayContext) error { + // Don't render - this is metadata only, user already knows they're resuming + return nil } // handleMistakeLimitReached handles mistake limit reached -func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text)) +func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *DisplayContext) error { + if dc.SystemRenderer != nil { + details := make(map[string]string) + if msg.Text != "" { + details["details"] = msg.Text + } + dc.SystemRenderer.RenderError( + "critical", + "Mistake Limit Reached", + "Cline has made too many consecutive mistakes and needs your guidance to proceed.", + details, + ) + fmt.Printf("\n**Approval required to continue.**\n") + return nil + } + return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true) } // handleAutoApprovalMaxReached handles auto-approval max reached -func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text)) +func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *DisplayContext) error { + if dc.SystemRenderer != nil { + details := make(map[string]string) + if msg.Text != "" { + details["reason"] = msg.Text + } + dc.SystemRenderer.RenderError( + "warning", + "Auto-Approval Limit Reached", + "The maximum number of auto-approved requests has been reached. Manual approval is now required.", + details, + ) + fmt.Printf("\n**Approval required to continue.**\n") + return nil + } + return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true) } // handleBrowserActionLaunch handles browser action launch requests -func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error { url := strings.TrimSpace(msg.Text) - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url)) + err := dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Cline wants to launch browser and navigate to: %s. Approval required.", url), true) + h.showApprovalHint(dc) + return err } // handleUseMcpServer handles MCP server usage requests -func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error { // Parse MCP server usage request type McpServerRequest struct { ServerName string `json:"serverName"` @@ -292,7 +295,7 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont var mcpReq McpServerRequest if err := json.Unmarshal([]byte(msg.Text), &mcpReq); err != nil { - return dc.Renderer.RenderMessage(timestamp, "MCP", msg.Text) + return dc.Renderer.RenderMessage("MCP", msg.Text, true) } var operation string @@ -305,22 +308,25 @@ func (h *AskHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayCont } } - return dc.Renderer.RenderMessage(timestamp, "MCP", - fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName)) + err := dc.Renderer.RenderMessage("MCP", + fmt.Sprintf("Cline wants to %s on the %s MCP server", operation, mcpReq.ServerName), true) + + h.showApprovalHint(dc) + return err } // handleNewTask handles new task creation requests -func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text)) +func (h *AskHandler) handleNewTask(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("NEW TASK", fmt.Sprintf("Cline wants to start a new task: %s. Approval required.", msg.Text), true) } // handleCondense handles conversation condensing requests -func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text)) +func (h *AskHandler) handleCondense(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("CONDENSE", fmt.Sprintf("Cline wants to condense the conversation: %s. Approval required.", msg.Text), true) } // handleReportBug handles bug report requests -func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext) error { var bugData struct { Title string `json:"title"` WhatHappened string `json:"what_happened"` @@ -330,10 +336,10 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext } if err := json.Unmarshal([]byte(msg.Text), &bugData); err != nil { - return dc.Renderer.RenderMessage(timestamp, "BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text)) + return dc.Renderer.RenderMessage("BUG REPORT", fmt.Sprintf("Cline wants to create a GitHub issue: %s. Approval required.", msg.Text), true) } - err := dc.Renderer.RenderMessage(timestamp, "BUG REPORT", "Cline wants to create a GitHub issue:") + err := dc.Renderer.RenderMessage("BUG REPORT", "Cline wants to create a GitHub issue:", true) if err != nil { return fmt.Errorf("failed to render handleReportBug: %w", err) } @@ -349,6 +355,6 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext } // handleDefault handles unknown ASK message types -func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "ASK", msg.Text) +func (h *AskHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("ASK", msg.Text, true) } diff --git a/cli/pkg/cli/handlers/handler.go b/cli/pkg/cli/handlers/handler.go index c4ac3e3fbca..bd8098ec24a 100644 --- a/cli/pkg/cli/handlers/handler.go +++ b/cli/pkg/cli/handlers/handler.go @@ -22,12 +22,16 @@ type MessageHandler interface { // DisplayContext provides context and utilities for message handlers type DisplayContext struct { - State *types.ConversationState - Renderer *display.Renderer - IsLast bool - IsPartial bool - Verbose bool - MessageIndex int + State *types.ConversationState + Renderer *display.Renderer + ToolRenderer *display.ToolRenderer + SystemRenderer *display.SystemMessageRenderer + IsLast bool + IsPartial bool + Verbose bool + MessageIndex int + IsStreamingMode bool + IsInteractive bool } // BaseHandler provides common functionality for message handlers @@ -94,15 +98,13 @@ func (r *HandlerRegistry) Handle(msg *types.ClineMessage, dc *DisplayContext) er // handleDefault provides default handling for unrecognized messages func (r *HandlerRegistry) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error { - timestamp := msg.GetTimestamp() - if msg.Text == "" { return nil } prefix := "RESPONSE:" - return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text) + return dc.Renderer.RenderMessage(prefix, msg.Text, true) } // GetHandlers returns all registered handlers diff --git a/cli/pkg/cli/handlers/say_handlers.go b/cli/pkg/cli/handlers/say_handlers.go index 09997b8458a..2f42d1ac228 100644 --- a/cli/pkg/cli/handlers/say_handlers.go +++ b/cli/pkg/cli/handlers/say_handlers.go @@ -5,7 +5,9 @@ import ( "fmt" "strings" + "github.com/cline/cli/pkg/cli/clerror" "github.com/cline/cli/pkg/cli/types" + "github.com/cline/cli/pkg/cli/output" ) // SayHandler handles SAY type messages @@ -31,269 +33,346 @@ func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error { switch msg.Say { case string(types.SayTypeTask): - return h.handleTask(msg, dc, timestamp) + return h.handleTask(msg, dc) case string(types.SayTypeError): - return h.handleError(msg, dc, timestamp) + return h.handleError(msg, dc) case string(types.SayTypeAPIReqStarted): - return h.handleAPIReqStarted(msg, dc, timestamp) + return h.handleAPIReqStarted(msg, dc) case string(types.SayTypeAPIReqFinished): - return h.handleAPIReqFinished(msg, dc, timestamp) + return h.handleAPIReqFinished(msg, dc) case string(types.SayTypeText): - return h.handleText(msg, dc, timestamp) + return h.handleText(msg, dc) case string(types.SayTypeReasoning): - return h.handleReasoning(msg, dc, timestamp) + return h.handleReasoning(msg, dc) case string(types.SayTypeCompletionResult): - return h.handleCompletionResult(msg, dc, timestamp) + return h.handleCompletionResult(msg, dc) case string(types.SayTypeUserFeedback): - return h.handleUserFeedback(msg, dc, timestamp) + return h.handleUserFeedback(msg, dc) case string(types.SayTypeUserFeedbackDiff): - return h.handleUserFeedbackDiff(msg, dc, timestamp) + return h.handleUserFeedbackDiff(msg, dc) case string(types.SayTypeAPIReqRetried): - return h.handleAPIReqRetried(msg, dc, timestamp) + return h.handleAPIReqRetried(msg, dc) + case string(types.SayTypeErrorRetry): + return h.handleErrorRetry(msg, dc) case string(types.SayTypeCommand): - return h.handleCommand(msg, dc, timestamp) + return h.handleCommand(msg, dc) case string(types.SayTypeCommandOutput): - return h.handleCommandOutput(msg, dc, timestamp) + return h.handleCommandOutput(msg, dc) case string(types.SayTypeTool): - return h.handleTool(msg, dc, timestamp) + return h.handleTool(msg, dc) case string(types.SayTypeShellIntegrationWarning): - return h.handleShellIntegrationWarning(msg, dc, timestamp) + return h.handleShellIntegrationWarning(msg, dc) case string(types.SayTypeBrowserActionLaunch): - return h.handleBrowserActionLaunch(msg, dc, timestamp) + return h.handleBrowserActionLaunch(msg, dc) case string(types.SayTypeBrowserAction): - return h.handleBrowserAction(msg, dc, timestamp) + return h.handleBrowserAction(msg, dc) case string(types.SayTypeBrowserActionResult): - return h.handleBrowserActionResult(msg, dc, timestamp) + return h.handleBrowserActionResult(msg, dc) case string(types.SayTypeMcpServerRequestStarted): - return h.handleMcpServerRequestStarted(msg, dc, timestamp) + return h.handleMcpServerRequestStarted(msg, dc) case string(types.SayTypeMcpServerResponse): - return h.handleMcpServerResponse(msg, dc, timestamp) + return h.handleMcpServerResponse(msg, dc) case string(types.SayTypeMcpNotification): - return h.handleMcpNotification(msg, dc, timestamp) + return h.handleMcpNotification(msg, dc) case string(types.SayTypeUseMcpServer): - return h.handleUseMcpServer(msg, dc, timestamp) + return h.handleUseMcpServer(msg, dc) case string(types.SayTypeDiffError): - return h.handleDiffError(msg, dc, timestamp) + return h.handleDiffError(msg, dc) case string(types.SayTypeDeletedAPIReqs): - return h.handleDeletedAPIReqs(msg, dc, timestamp) + return h.handleDeletedAPIReqs(msg, dc) case string(types.SayTypeClineignoreError): - return h.handleClineignoreError(msg, dc, timestamp) + return h.handleClineignoreError(msg, dc) case string(types.SayTypeCheckpointCreated): return h.handleCheckpointCreated(msg, dc, timestamp) case string(types.SayTypeLoadMcpDocumentation): - return h.handleLoadMcpDocumentation(msg, dc, timestamp) + return h.handleLoadMcpDocumentation(msg, dc) case string(types.SayTypeInfo): - return h.handleInfo(msg, dc, timestamp) + return h.handleInfo(msg, dc) case string(types.SayTypeTaskProgress): - return h.handleTaskProgress(msg, dc, timestamp) + return h.handleTaskProgress(msg, dc) default: - return h.handleDefault(msg, dc, timestamp) + return h.handleDefault(msg, dc) } } // handleTask handles task messages -func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleTask(msg *types.ClineMessage, dc *DisplayContext) error { return nil } // handleError handles error messages -func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "ERROR", msg.Text) +func (h *SayHandler) handleError(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("ERROR", msg.Text, true) } // handleAPIReqStarted handles API request started messages -func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleAPIReqStarted(msg *types.ClineMessage, dc *DisplayContext) error { // Parse API request info apiInfo := types.APIRequestInfo{Cost: -1} if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err != nil { - return dc.Renderer.RenderMessage(timestamp, "API INFO", msg.Text) + return dc.Renderer.RenderMessage("API INFO", msg.Text, true) + } + + // Check for streaming failed message with error details + if apiInfo.StreamingFailedMessage != "" { + clineErr, _ := clerror.ParseClineError(apiInfo.StreamingFailedMessage) + if clineErr != nil { + return h.renderClineError(clineErr, dc) + } } // Handle different API request states if apiInfo.CancelReason != "" { if apiInfo.CancelReason == "user_cancelled" { - return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Cancelled") + return dc.Renderer.RenderMessage("API INFO", "Request Cancelled", true) } else if apiInfo.CancelReason == "retries_exhausted" { - return dc.Renderer.RenderMessage(timestamp, "API INFO", "Request Failed (Retries Exhausted)") + return dc.Renderer.RenderMessage("API INFO", "Request Failed (Retries Exhausted)", true) } - return dc.Renderer.RenderMessage(timestamp, "API INFO", "Streaming Failed") + return dc.Renderer.RenderMessage("API INFO", "Streaming Failed", true) } if apiInfo.Cost >= 0 { - return dc.Renderer.RenderAPI(timestamp, "Request completed", &apiInfo) + return dc.Renderer.RenderAPI("request completed", &apiInfo) } // Check for retry status if apiInfo.RetryStatus != nil { - return dc.Renderer.RenderRetry(timestamp, + return dc.Renderer.RenderRetry( apiInfo.RetryStatus.Attempt, apiInfo.RetryStatus.MaxAttempts, apiInfo.RetryStatus.DelaySec) } - return dc.Renderer.RenderAPI(timestamp, "Processing request", &apiInfo) + return dc.Renderer.RenderAPI("processing request", &apiInfo) +} + +// renderClineError renders a ClineError with appropriate formatting based on type +func (h *SayHandler) renderClineError(err *clerror.ClineError, dc *DisplayContext) error { + if dc.SystemRenderer == nil { + return dc.Renderer.RenderMessage("ERROR", err.Message, true) + } + + switch err.GetErrorType() { + case clerror.ErrorTypeBalance: + return dc.SystemRenderer.RenderBalanceError(err) + case clerror.ErrorTypeAuth: + return dc.SystemRenderer.RenderAuthError(err) + case clerror.ErrorTypeRateLimit: + return dc.SystemRenderer.RenderRateLimitError(err) + default: + return dc.SystemRenderer.RenderAPIError(err) + } } // handleAPIReqFinished handles API request finished messages -func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleAPIReqFinished(msg *types.ClineMessage, dc *DisplayContext) error { // This message type is typically not displayed as it's handled by the started message return nil } // handleText handles regular text messages -func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } // Special case for the user's task input - prefix := "ASST TEXT" if dc.MessageIndex == 0 { - prefix = "USER" + markdown := formatUserMessage(msg.Text) + rendered := dc.Renderer.RenderMarkdown(markdown) + output.Printf("%s", rendered) + output.Printf("\n") + return nil } - return dc.Renderer.RenderMessage(timestamp, prefix, msg.Text) + // Regular Cline text response + var rendered string + if dc.IsStreamingMode { + // In streaming mode, header already shown by partial stream + rendered = dc.Renderer.RenderMarkdown(msg.Text) + output.Printf("%s\n", rendered) + } else { + // In non-streaming mode, render header + body together + markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text) + rendered = dc.Renderer.RenderMarkdown(markdown) + output.Printf("\n%s\n", rendered) + } + return nil } // handleReasoning handles reasoning messages -func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } - return dc.Renderer.RenderMessage(timestamp, "THINKING", msg.Text) + var rendered string + if dc.IsStreamingMode { + // In streaming mode, header already shown by partial stream + rendered = dc.Renderer.RenderMarkdown(msg.Text) + output.Printf("%s\n", rendered) + } else { + // In non-streaming mode, render header + body together + markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text) + rendered = dc.Renderer.RenderMarkdown(markdown) + output.Printf("\n%s\n", rendered) + } + return nil } -func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *DisplayContext) error { text := msg.Text if strings.HasSuffix(text, "HAS_CHANGES") { text = strings.TrimSuffix(text, "HAS_CHANGES") } - return dc.Renderer.RenderMessage(timestamp, "RESULT", text) + var rendered string + if dc.IsStreamingMode { + // In streaming mode, header already shown by partial stream + rendered = dc.Renderer.RenderMarkdown(text) + output.Printf("%s\n", rendered) + } else { + // In non-streaming mode, render header + body together + markdown := fmt.Sprintf("### Task completed\n\n%s", text) + rendered = dc.Renderer.RenderMarkdown(markdown) + output.Printf("\n%s\n", rendered) + } + return nil } +func formatUserMessage(text string) string { + lines := strings.Split(text, "\n") + + // Wrap each line in backticks + for i, line := range lines { + if line != "" { + lines[i] = fmt.Sprintf("`%s`", line) + } + } + + return strings.Join(lines, "\n") +} + + // handleUserFeedback handles user feedback messages -func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text != "" { - return dc.Renderer.RenderMessage(timestamp, "USER", msg.Text) + markdown := formatUserMessage(msg.Text) + rendered := dc.Renderer.RenderMarkdown(markdown) + output.Printf("%s", rendered) + return nil } else { - return dc.Renderer.RenderMessage(timestamp, "USER", "[Provided feedback without text]") + return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true) } } // handleUserFeedbackDiff handles user feedback diff messages -func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleUserFeedbackDiff(msg *types.ClineMessage, dc *DisplayContext) error { var toolMsg types.ToolMessage if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil { - return dc.Renderer.RenderMessage(timestamp, "USER DIFF", msg.Text) + return dc.Renderer.RenderMessage("USER DIFF", msg.Text, true) } message := fmt.Sprintf("User manually edited: %s\n\nDiff:\n%s", toolMsg.Path, toolMsg.Diff) - return dc.Renderer.RenderMessage(timestamp, "USER DIFF", message) + return dc.Renderer.RenderMessage("USER DIFF", message, true) } // handleAPIReqRetried handles API request retry messages -func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "API INFO", "Retrying request") +func (h *SayHandler) handleAPIReqRetried(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("API INFO", "Retrying request", true) +} + +// handleErrorRetry handles error retry status messages +func (h *SayHandler) handleErrorRetry(msg *types.ClineMessage, dc *DisplayContext) error { + // Parse retry info from message text + type ErrorRetryInfo struct { + Attempt int `json:"attempt"` + MaxAttempts int `json:"maxAttempts"` + DelaySeconds int `json:"delaySeconds"` + Failed bool `json:"failed"` + } + + var retryInfo ErrorRetryInfo + if err := json.Unmarshal([]byte(msg.Text), &retryInfo); err != nil { + // Fallback to simple message if parsing fails + return dc.Renderer.RenderMessage("API INFO", "Auto-retry in progress", true) + } + + if retryInfo.Failed { + // Retry failed after max attempts + message := fmt.Sprintf("Auto-retry failed after %d attempts. Manual intervention required.", retryInfo.MaxAttempts) + if dc.SystemRenderer != nil { + return dc.SystemRenderer.RenderWarning("Auto-Retry Failed", message) + } + return dc.Renderer.RenderMessage("WARNING", message, true) + } + + // Retry in progress + message := fmt.Sprintf("Attempt %d/%d - Retrying in %d seconds...", + retryInfo.Attempt, retryInfo.MaxAttempts, retryInfo.DelaySeconds) + return dc.Renderer.RenderMessage("API INFO", message, true) } // handleCommand handles command execution announcements -func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } - command := strings.TrimSpace(msg.Text) - - err := dc.Renderer.RenderMessage(timestamp, "TERMINAL", "Running command:") - if err != nil { - return fmt.Errorf("failed to render handleCommand: %w", err) - } - - fmt.Printf("\n```shell\n%s\n```\n", command) + // Use unified ToolRenderer + rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text) + output.Print(rendered) return nil } // handleCommandOutput handles command output messages -func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - commandOutput := msg.Text - return dc.Renderer.RenderMessage(timestamp, "TERMINAL", fmt.Sprintf("Current terminal output: %s", commandOutput)) +func (h *SayHandler) 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, timestamp string) error { +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(timestamp, "TOOL", msg.Text) - } - - return h.renderToolMessage(&tool, dc, timestamp) -} - -func (h *SayHandler) renderToolMessage(tool *types.ToolMessage, dc *DisplayContext, timestamp string) error { - switch tool.Tool { - case string(types.ToolTypeEditedExistingFile): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline edited file: %s", tool.Path)) - case string(types.ToolTypeNewFileCreated): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline created file: %s", tool.Path)) - case string(types.ToolTypeReadFile): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline read file: %s", tool.Path)) - case string(types.ToolTypeListFilesTopLevel): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed files in: %s", tool.Path)) - case string(types.ToolTypeListFilesRecursive): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline recursively listed files in: %s", tool.Path)) - case string(types.ToolTypeSearchFiles): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline searched for '%s' in: %s", tool.Regex, tool.Path)) - case string(types.ToolTypeWebFetch): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline fetched URL: %s", tool.Path)) - case string(types.ToolTypeListCodeDefinitionNames): - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline listed code definitions for: %s", tool.Path)) - case string(types.ToolTypeSummarizeTask): - dc.Renderer.RenderMessage(timestamp, "TOOL", "Cline condensed the conversation") - default: - dc.Renderer.RenderMessage(timestamp, "TOOL", fmt.Sprintf("Cline executed tool: %s", tool.Tool)) + return dc.Renderer.RenderMessage("TOOL", msg.Text, true) } - // Skip content preview for readFile and webFetch tools - if tool.Tool == string(types.ToolTypeReadFile) || tool.Tool == string(types.ToolTypeWebFetch) { - return nil - } - - // Show content preview, truncating if necessary - preview := tool.Content - if preview != "" { - preview = strings.TrimSpace(tool.Content) - if len(preview) > 1000 { - preview = preview[:1000] + "..." - } - fmt.Printf("Content: %s\n", preview) - } + // Use unified ToolRenderer + rendered := dc.ToolRenderer.RenderToolExecution(&tool) + output.Print(rendered) return nil } // handleShellIntegrationWarning handles shell integration warning messages -func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.") +func (h *SayHandler) handleShellIntegrationWarning(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("WARNING", "Shell Integration Unavailable - Cline won't be able to view the command's output.", true) } // handleBrowserActionLaunch handles browser action launch messages -func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleBrowserActionLaunch(msg *types.ClineMessage, dc *DisplayContext) error { url := msg.Text if url == "" { return nil } - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Launching browser at: %s", url)) + return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Launching browser at: %s", url), true) } // handleBrowserAction handles browser action messages -func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } @@ -306,27 +385,27 @@ func (h *SayHandler) handleBrowserAction(msg *types.ClineMessage, dc *DisplayCon var actionData BrowserActionData if err := json.Unmarshal([]byte(msg.Text), &actionData); err != nil { - return dc.Renderer.RenderMessage(timestamp, "BROWSER", msg.Text) + return dc.Renderer.RenderMessage("BROWSER", msg.Text, true) } // Special handling for type action if actionData.Action == "type" && actionData.Text != "" { actionText := fmt.Sprintf("type '%s'", actionData.Text) - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText)) + return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true) } // Special handling for click action if actionData.Action == "click" && actionData.Coordinate != "" { actionText := fmt.Sprintf("click (%s)", actionData.Coordinate) - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionText)) + return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionText), true) } // Generic handling for all other actions - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Next action: %s", actionData.Action)) + return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Next action: %s", actionData.Action), true) } // handleBrowserActionResult handles browser action result messages -func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } @@ -340,79 +419,103 @@ func (h *SayHandler) handleBrowserActionResult(msg *types.ClineMessage, dc *Disp var result BrowserActionResult if err := json.Unmarshal([]byte(msg.Text), &result); err != nil { - return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed") + return dc.Renderer.RenderMessage("BROWSER", "Action completed", true) } // If we have logs, include them in the message if result.Logs != "" { - return dc.Renderer.RenderMessage(timestamp, "BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs)) + return dc.Renderer.RenderMessage("BROWSER", fmt.Sprintf("Action completed with logs: '%s'", result.Logs), true) } // Default case - return dc.Renderer.RenderMessage(timestamp, "BROWSER", "Action completed") + return dc.Renderer.RenderMessage("BROWSER", "Action completed", true) } // handleMcpServerRequestStarted handles MCP server request started messages -func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "MCP", "Sending request to server") +func (h *SayHandler) handleMcpServerRequestStarted(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("MCP", "Sending request to server", true) } // handleMcpServerResponse handles MCP server response messages -func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server response: %s", msg.Text)) +func (h *SayHandler) handleMcpServerResponse(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server response: %s", msg.Text), true) } // handleMcpNotification handles MCP notification messages -func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "MCP", fmt.Sprintf("Server notification: %s", msg.Text)) +func (h *SayHandler) handleMcpNotification(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("MCP", fmt.Sprintf("Server notification: %s", msg.Text), true) } // handleUseMcpServer handles MCP server usage messages -func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "MCP", "Server operation approved") +func (h *SayHandler) handleUseMcpServer(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("MCP", "Server operation approved", true) } // handleDiffError handles diff error messages -func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.") +func (h *SayHandler) handleDiffError(msg *types.ClineMessage, dc *DisplayContext) error { + if dc.SystemRenderer != nil { + return dc.SystemRenderer.RenderWarning( + "Diff Edit Failure", + "The model used search patterns that don't match anything in the file. Retrying...", + ) + } + return dc.Renderer.RenderMessage("WARNING", "Diff Edit Failure - The model used an invalid diff edit format or used search patterns that don't match anything in the file.", true) } // handleDeletedAPIReqs handles deleted API requests messages -func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - // This message includes api metrics of deleted messages, which we do not log - return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint restored") +func (h *SayHandler) handleDeletedAPIReqs(msg *types.ClineMessage, dc *DisplayContext) error { + // Don't render - this is internal metadata (aggregated API metrics from deleted checkpoint messages) + return nil } // handleClineignoreError handles .clineignore error messages -func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text)) +func (h *SayHandler) handleClineignoreError(msg *types.ClineMessage, dc *DisplayContext) error { + if dc.SystemRenderer != nil { + return dc.SystemRenderer.RenderInfo( + "Access Denied", + fmt.Sprintf("Cline tried to access `%s` which is blocked by the .clineignore file.", msg.Text), + ) + } + return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Access Denied - Cline tried to access %s which is blocked by the .clineignore file", msg.Text), true) } -// handleCheckpointCreated handles checkpoint created messages func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Checkpoint created") + if dc.SystemRenderer != nil { + return dc.SystemRenderer.RenderCheckpoint(timestamp, msg.Timestamp) + } + // Fallback to basic renderer if SystemRenderer not available + markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp) + rendered := dc.Renderer.RenderMarkdown(markdown) + output.Print(rendered) + return nil } // handleLoadMcpDocumentation handles load MCP documentation messages -func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "GEN INFO", "Loading MCP documentation") +func (h *SayHandler) handleLoadMcpDocumentation(msg *types.ClineMessage, dc *DisplayContext) error { + if dc.SystemRenderer != nil { + return dc.SystemRenderer.RenderInfo("MCP", "Loading MCP documentation") + } + return dc.Renderer.RenderMessage("INFO", "Loading MCP documentation", true) } // handleInfo handles info messages -func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleInfo(msg *types.ClineMessage, dc *DisplayContext) error { return nil } // handleTaskProgress handles task progress messages -func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { +func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayContext) error { if msg.Text == "" { return nil } - return dc.Renderer.RenderMessage(timestamp, "PROGRESS", fmt.Sprintf("Task Checklist: %s", msg.Text)) + markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text) + rendered := dc.Renderer.RenderMarkdown(markdown) + output.Printf("\n%s\n", rendered) + return nil } // handleDefault handles unknown SAY message types -func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext, timestamp string) error { - return dc.Renderer.RenderMessage(timestamp, "SAY", msg.Text) +func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error { + return dc.Renderer.RenderMessage("SAY", msg.Text, true) } diff --git a/cli/pkg/cli/instances.go b/cli/pkg/cli/instances.go index b829e6e4f94..ab438247478 100644 --- a/cli/pkg/cli/instances.go +++ b/cli/pkg/cli/instances.go @@ -4,16 +4,60 @@ import ( "context" "fmt" "os" + "strings" "syscall" "text/tabwriter" "time" + "github.com/cline/cli/pkg/cli/display" "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/common" + client2 "github.com/cline/grpc-go/client" "github.com/cline/grpc-go/cline" "github.com/spf13/cobra" "google.golang.org/grpc/health/grpc_health_v1" ) +const ( + platformCLI = "CLI" + platformJetBrains = "JetBrains" + platformNA = "N/A" + hostPlatformCLI = "Cline CLI" // Value returned by host bridge for CLI instances +) + +// detectInstancePlatform connects to an instance's host bridge and determines its platform +func detectInstancePlatform(ctx context.Context, instance *common.CoreInstanceInfo) (string, error) { + hostTarget, err := common.NormalizeAddressForGRPC(instance.HostServiceAddress) + if err != nil { + return platformNA, err + } + + hostClient, err := client2.NewClineClient(hostTarget) + if err != nil { + return platformNA, err + } + defer hostClient.Disconnect() + + if err := hostClient.Connect(ctx); err != nil { + return platformNA, err + } + + hostVersion, err := hostClient.Env.GetHostVersion(ctx, &cline.EmptyRequest{}) + if err != nil { + return platformNA, err + } + + if hostVersion.Platform == nil { + return platformNA, fmt.Errorf("host returned nil platform") + } + + platformStr := *hostVersion.Platform + if platformStr == hostPlatformCLI { + return platformCLI, nil + } + return platformJetBrains, nil +} + func NewInstanceCommand() *cobra.Command { cmd := &cobra.Command{ Use: "instance", @@ -23,7 +67,7 @@ func NewInstanceCommand() *cobra.Command { } cmd.AddCommand(newInstanceListCommand()) - cmd.AddCommand(newInstanceUseCommand()) + cmd.AddCommand(newInstanceDefaultCommand()) cmd.AddCommand(newInstanceNewCommand()) cmd.AddCommand(newInstanceKillCommand()) @@ -31,7 +75,7 @@ func NewInstanceCommand() *cobra.Command { } func newInstanceKillCommand() *cobra.Command { - var killAll bool + var killAllCLI bool cmd := &cobra.Command{ Use: "kill
", @@ -39,11 +83,11 @@ func newInstanceKillCommand() *cobra.Command { Short: "Kill a Cline instance by address", Long: `Kill a running Cline instance and clean up its registry entry.`, Args: func(cmd *cobra.Command, args []string) error { - if killAll && len(args) > 0 { - return fmt.Errorf("cannot specify both --all flag and address argument") + if killAllCLI && len(args) > 0 { + return fmt.Errorf("cannot specify both --all-cli flag and address argument") } - if !killAll && len(args) != 1 { - return fmt.Errorf("requires exactly one address argument when --all is not specified") + if !killAllCLI && len(args) != 1 { + return fmt.Errorf("requires exactly one address argument when --all-cli is not specified") } return nil }, @@ -55,93 +99,67 @@ func newInstanceKillCommand() *cobra.Command { ctx := cmd.Context() registry := global.Clients.GetRegistry() - if killAll { - return killAllInstances(ctx, registry) + if killAllCLI { + return killAllCLIInstances(ctx, registry) } else { - return killSingleInstance(ctx, registry, args[0]) + return global.KillInstanceByAddress(ctx, registry, args[0]) } }, } - cmd.Flags().BoolVar(&killAll, "all", false, "kill all running instances") + cmd.Flags().BoolVarP(&killAllCLI, "all-cli", "a", false, "kill all running CLI instances (excludes JetBrains)") return cmd } -func killSingleInstance(ctx context.Context, registry *global.ClientRegistry, address string) error { - // Check if the instance exists in the registry - _, err := registry.GetInstance(address) - if err != nil { - return fmt.Errorf("instance %s not found in registry", address) - } - - fmt.Printf("Killing instance: %s\n", address) - - // Get gRPC client and process info - client, err := registry.GetClient(ctx, address) - if err != nil { - return fmt.Errorf("failed to connect to instance %s: %w", address, err) - } - - processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}) +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 get process info for instance %s: %w", address, err) + return fmt.Errorf("failed to list instances: %w", err) } - pid := int(processInfo.ProcessId) - fmt.Printf("Terminating process PID %d...\n", pid) - - // Kill the process - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - return fmt.Errorf("failed to kill process %d: %w", pid, err) + if len(instances) == 0 { + fmt.Println("No Cline instances found to kill.") + return nil } - // Wait for the instance to remove itself from registry - fmt.Printf("Waiting for instance to clean up registry entry...\n") - for i := 0; i < 5; i++ { - time.Sleep(1 * time.Second) - if !registry.HasInstanceAtAddress(address) { - fmt.Printf("Instance %s successfully killed and removed from registry.\n", address) - - // Update default instance if needed - instances, err := registry.ListInstancesCleaned(ctx) - if err == nil && len(instances) > 0 { - // ensureDefaultInstance logic will handle setting a new default - defaultInstance := registry.GetDefaultInstance() - if defaultInstance == address || defaultInstance == "" { - if len(instances) > 0 { - if err := registry.SetDefaultInstance(instances[0].Address); err == nil { - fmt.Printf("Updated default instance to: %s\n", instances[0].Address) - } - } + // 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) } } - - return nil } } - return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds") -} - -func killAllInstances(ctx context.Context, registry *global.ClientRegistry) error { - // Get all instances from registry - instances, err := registry.ListInstancesCleaned(ctx) - if err != nil { - return fmt.Errorf("failed to list instances: %w", err) - } - - if len(instances) == 0 { - fmt.Println("No Cline instances found to kill.") + 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 instances...\n", len(instances)) + fmt.Printf("Killing %d CLI instance(s)...\n", len(cliInstances)) + if skippedNonCLI > 0 { + fmt.Printf("Skipping %d JetBrains instance(s).\n", skippedNonCLI) + } var killResults []killResult + killedAddresses := make(map[string]bool) - // Kill all instances - for _, instance := range instances { + // Kill all CLI instances + for _, instance := range cliInstances { result := killInstanceProcess(ctx, registry, instance.Address) killResults = append(killResults, result) @@ -151,31 +169,42 @@ func killAllInstances(ctx context.Context, registry *global.ClientRegistry) erro fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address) } else { fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid) + killedAddresses[instance.Address] = true } } - // Wait for all instances to clean up their registry entries - fmt.Printf("Waiting for instances to clean up registry entries...\n") + // Wait for killed instances to clean up their registry entries + if len(killedAddresses) > 0 { + fmt.Printf("Waiting for instances to clean up registry entries...\n") - maxWaitTime := 10 // seconds - for i := 0; i < maxWaitTime; i++ { - time.Sleep(1 * time.Second) + maxWaitTime := 10 // seconds + for i := 0; i < maxWaitTime; i++ { + time.Sleep(1 * time.Second) - remainingInstances, err := registry.ListInstancesCleaned(ctx) - if err != nil { - fmt.Printf("Warning: failed to check registry status: %v\n", err) - continue - } - - if len(remainingInstances) == 0 { - fmt.Printf("✓ All instances successfully removed from registry.\n") - break - } + remainingInstances, err := registry.ListInstancesCleaned(ctx) + if err != nil { + fmt.Printf("Warning: failed to check registry status: %v\n", err) + continue + } - if i == maxWaitTime-1 { - fmt.Printf("⚠ %d instances still in registry after %d seconds\n", len(remainingInstances), maxWaitTime) + // Check if any of the killed instances are still in the registry + stillPresent := []string{} for _, remaining := range remainingInstances { - fmt.Printf(" - %s\n", remaining.Address) + if killedAddresses[remaining.Address] { + stillPresent = append(stillPresent, remaining.Address) + } + } + + if len(stillPresent) == 0 { + fmt.Printf("✓ All killed instances successfully removed from registry.\n") + break + } + + if i == maxWaitTime-1 { + fmt.Printf("⚠ %d killed instance(s) still in registry after %d seconds\n", len(stillPresent), maxWaitTime) + for _, addr := range stillPresent { + fmt.Printf(" - %s\n", addr) + } } } } @@ -267,14 +296,22 @@ func newInstanceListCommand() *cobra.Command { return nil } - // Always output a table - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ADDRESS\tSTATUS\tVERSION\tLAST SEEN\tPID\tDEFAULT") + // Build instance data + type instanceRow struct { + address string + status string + version string + lastSeen string + pid string + platform string + isDefault string + } + var rows []instanceRow for _, instance := range instances { isDefault := "" if instance.Address == defaultInstance { - isDefault = "*" + isDefault = "✓" } lastSeen := instance.LastSeen.Format("15:04:05") @@ -282,9 +319,11 @@ func newInstanceListCommand() *cobra.Command { lastSeen = instance.LastSeen.Format("2006-01-02") } - // Get PID via RPC if instance is healthy - pid := "N/A" + // Get PID and platform via RPC if instance is healthy + pid := platformNA + platform := platformNA if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING { + // Get PID from core if client, err := registry.GetClient(ctx, instance.Address); err == nil { if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil { pid = fmt.Sprintf("%d", processInfo.ProcessId) @@ -294,19 +333,84 @@ func newInstanceListCommand() *cobra.Command { } } } + + // Get platform from host bridge + if detectedPlatform, err := detectInstancePlatform(ctx, instance); err == nil { + platform = detectedPlatform + } + } + + 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, + )) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", - instance.Address, - instance.Status, - instance.Version, - lastSeen, - pid, - isDefault, - ) + // Render the markdown table with terminal width for nice table layout + mdRenderer, err := display.NewMarkdownRendererForTerminal() + if err != nil { + // Fallback to plain table if markdown renderer fails + fmt.Println(markdown.String()) + } else { + rendered, err := mdRenderer.Render(markdown.String()) + if err != nil { + fmt.Println(markdown.String()) + } else { + // Post-process to colorize status values + colorRenderer := display.NewRenderer(global.Config.OutputFormat) + rendered = strings.ReplaceAll(rendered, "SERVING", colorRenderer.Green("SERVING")) + rendered = strings.ReplaceAll(rendered, "✓", colorRenderer.Green("✓")) + rendered = strings.ReplaceAll(rendered, "NOT_SERVING", colorRenderer.Red("NOT_SERVING")) + rendered = strings.ReplaceAll(rendered, "UNKNOWN", colorRenderer.Yellow("UNKNOWN")) + + fmt.Print(strings.TrimLeft(rendered, "\n")) + } + fmt.Println("\n") + } } - w.Flush() return nil }, } @@ -314,10 +418,10 @@ func newInstanceListCommand() *cobra.Command { return cmd } -func newInstanceUseCommand() *cobra.Command { +func newInstanceDefaultCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "use
", - Aliases: []string{"u"}, + Use: "default
", + Aliases: []string{"d"}, Short: "Set the default Cline instance", Long: `Set the default Cline instance to use for subsequent commands.`, Args: cobra.ExactArgs(1), @@ -350,6 +454,8 @@ func newInstanceUseCommand() *cobra.Command { } func newInstanceNewCommand() *cobra.Command { + var setDefault bool + cmd := &cobra.Command{ Use: "new", Aliases: []string{"n"}, @@ -374,15 +480,27 @@ func newInstanceNewCommand() *cobra.Command { fmt.Printf(" Core Port: %d\n", instance.CorePort()) fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort()) - // Check if this is now the default instance registry := global.Clients.GetRegistry() - if registry.GetDefaultInstance() == instance.Address { - fmt.Printf(" Status: Default instance\n") + + // If --default flag provided, set this instance as the default + if setDefault { + if err := registry.SetDefaultInstance(instance.Address); err != nil { + fmt.Printf("Warning: Failed to set as default: %v\n", err) + } else { + fmt.Printf(" Status: Set as default instance\n") + } + } else { + // Otherwise, check if EnsureDefaultInstance already set it as default + if registry.GetDefaultInstance() == instance.Address { + fmt.Printf(" Status: Default instance\n") + } } return nil }, } + cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance") + return cmd -} +} \ No newline at end of file diff --git a/cli/pkg/cli/logs.go b/cli/pkg/cli/logs.go new file mode 100644 index 00000000000..ea2ae72939e --- /dev/null +++ b/cli/pkg/cli/logs.go @@ -0,0 +1,382 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/cline/cli/pkg/cli/display" + "github.com/cline/cli/pkg/cli/global" + "github.com/spf13/cobra" +) + +type logFileInfo struct { + name string + path string + size int64 + created time.Time +} + +func NewLogsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Aliases: []string{"log", "l"}, + Short: "Manage Cline log files", + Long: `List and manage log files created by Cline instances.`, + } + + cmd.AddCommand(newLogsListCommand()) + cmd.AddCommand(newLogsCleanCommand()) + cmd.AddCommand(newLogsPathCommand()) + + return cmd +} + +func newLogsListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"l", "ls"}, + Short: "List all log files", + Long: `List all log files in the Cline logs directory with their sizes and ages.`, + RunE: func(cmd *cobra.Command, args []string) error { + if global.Config == nil { + return fmt.Errorf("config not initialized") + } + + logsDir := filepath.Join(global.Config.ConfigPath, "logs") + logs, err := listLogFiles(logsDir) + if err != nil { + return fmt.Errorf("failed to list log files: %w", err) + } + + if len(logs) == 0 { + fmt.Println("No log files found.") + fmt.Printf("Log files will be created in: %s\n", logsDir) + return nil + } + + return renderLogsTable(logs, false) + }, + } + + return cmd +} + +func newLogsCleanCommand() *cobra.Command { + var olderThan int + var all bool + var dryRun bool + + cmd := &cobra.Command{ + Use: "clean", + Aliases: []string{"c"}, + Short: "Delete old log files", + Long: `Delete log files older than a specified number of days.`, + RunE: func(cmd *cobra.Command, args []string) error { + if global.Config == nil { + return fmt.Errorf("config not initialized") + } + + logsDir := filepath.Join(global.Config.ConfigPath, "logs") + logs, err := listLogFiles(logsDir) + if err != nil { + return fmt.Errorf("failed to list log files: %w", err) + } + + var toDelete []logFileInfo + if all { + toDelete = logs + } else { + toDelete = filterOldLogs(logs, olderThan) + } + + if len(toDelete) == 0 { + if all { + fmt.Println("No log files to delete.") + } else { + fmt.Printf("No log files older than %d days found.\n", olderThan) + } + return nil + } + + // Calculate total size + var totalSize int64 + for _, log := range toDelete { + totalSize += log.size + } + + if dryRun { + fmt.Println("The following log files will be deleted:\n") + if err := renderLogsTable(toDelete, true); err != nil { + return err + } + fileWord := "files" + if len(toDelete) == 1 { + fileWord = "file" + } + fmt.Printf("\nSummary: %d %s will be deleted (%s freed)\n", len(toDelete), fileWord, formatFileSize(totalSize)) + fmt.Println("\nRun without --dry-run to actually delete these files.") + return nil + } + + // Actually delete the files + count, bytesFreed, err := deleteLogFiles(toDelete) + if err != nil { + return fmt.Errorf("failed to delete log files: %w", err) + } + + fileWord := "files" + if count == 1 { + fileWord = "file" + } + fmt.Printf("Deleted %d log %s (%s freed)\n", count, fileWord, formatFileSize(bytesFreed)) + return nil + }, + } + + cmd.Flags().IntVar(&olderThan, "older-than", 7, "delete logs older than N days") + cmd.Flags().BoolVar(&all, "all", false, "delete all log files") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting") + + return cmd +} + +func newLogsPathCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "path", + Short: "Print the logs directory path", + Long: `Print the absolute path to the Cline logs directory.`, + RunE: func(cmd *cobra.Command, args []string) error { + if global.Config == nil { + return fmt.Errorf("config not initialized") + } + + logsDir := filepath.Join(global.Config.ConfigPath, "logs") + fmt.Println(logsDir) + return nil + }, + } + + return cmd +} + +// Helper functions + +func listLogFiles(logsDir string) ([]logFileInfo, error) { + // Check if logs directory exists + if _, err := os.Stat(logsDir); os.IsNotExist(err) { + return []logFileInfo{}, nil + } + + entries, err := os.ReadDir(logsDir) + if err != nil { + return nil, err + } + + var logs []logFileInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Only process .log files + if !strings.HasSuffix(entry.Name(), ".log") { + continue + } + + // Parse timestamp from filename + created, err := parseTimestampFromFilename(entry.Name()) + if err != nil { + // Skip files we can't parse + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + logs = append(logs, logFileInfo{ + name: entry.Name(), + path: filepath.Join(logsDir, entry.Name()), + size: info.Size(), + created: created, + }) + } + + // Sort by created time (newest first) + sort.Slice(logs, func(i, j int) bool { + return logs[i].created.After(logs[j].created) + }) + + return logs, nil +} + +func parseTimestampFromFilename(filename string) (time.Time, error) { + // Expected format: cline-core-2025-10-12-21-30-45-localhost-51051.log + // or: cline-host-2025-10-12-21-30-45-localhost-52051.log + + parts := strings.Split(filename, "-") + if len(parts) < 8 { + return time.Time{}, fmt.Errorf("invalid filename format") + } + + // Extract timestamp parts: YYYY-MM-DD-HH-mm-ss + // They should be at indices 2-7 + timestampStr := strings.Join(parts[2:8], "-") + + // Parse as local time since the filename timestamp is created in local time + parsedTime, err := time.ParseInLocation("2006-01-02-15-04-05", timestampStr, time.Local) + if err != nil { + return time.Time{}, err + } + + return parsedTime, nil +} + +func filterOldLogs(logs []logFileInfo, olderThanDays int) []logFileInfo { + cutoff := time.Now().AddDate(0, 0, -olderThanDays) + var filtered []logFileInfo + + for _, log := range logs { + if log.created.Before(cutoff) { + filtered = append(filtered, log) + } + } + + return filtered +} + +func deleteLogFiles(files []logFileInfo) (int, int64, error) { + var count int + var bytesFreed int64 + + for _, file := range files { + if err := os.Remove(file.path); err != nil { + return count, bytesFreed, err + } + count++ + bytesFreed += file.size + } + + return count, bytesFreed, nil +} + +func formatFileSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func formatAge(t time.Time) string { + duration := time.Since(t) + + if duration < time.Hour { + minutes := int(duration.Minutes()) + return fmt.Sprintf("%dm ago", minutes) + } + + if duration < 24*time.Hour { + hours := int(duration.Hours()) + return fmt.Sprintf("%dh ago", hours) + } + + if duration < 7*24*time.Hour { + days := int(duration.Hours() / 24) + return fmt.Sprintf("%dd ago", days) + } + + weeks := int(duration.Hours() / 24 / 7) + return fmt.Sprintf("%dw ago", weeks) +} + +func renderLogsTable(logs []logFileInfo, markForDeletion bool) error { + // Build table data + type tableRow struct { + filename string + size string + created string + age string + } + + var rows []tableRow + for _, log := range logs { + rows = append(rows, tableRow{ + filename: log.name, + size: formatFileSize(log.size), + created: log.created.Format("2006-01-02 15:04:05"), + age: formatAge(log.created), + }) + } + + // Check output format + if global.Config.OutputFormat == "plain" { + // Use tabwriter for plain output + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "FILENAME\tSIZE\tCREATED\tAGE") + + for _, row := range rows { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + row.filename, + row.size, + row.created, + row.age, + ) + } + + w.Flush() + return nil + } + + // Use markdown table for rich output + colorRenderer := display.NewRenderer(global.Config.OutputFormat) + var markdown strings.Builder + markdown.WriteString("| **FILENAME** | **SIZE** | **CREATED** | **AGE** |\n") + markdown.WriteString("|--------------|----------|-------------|---------|") + + for _, row := range rows { + line := fmt.Sprintf("\n| %s | %s | %s | %s |", + row.filename, + row.size, + row.created, + row.age, + ) + + // If marking for deletion, wrap in red + if markForDeletion { + line = colorRenderer.Red(line) + } + + markdown.WriteString(line) + } + + // Render the markdown table + renderer, err := display.NewMarkdownRendererForTerminal() + if err != nil { + // Fallback to plain markdown if renderer fails + fmt.Println(markdown.String()) + return nil + } + + rendered, err := renderer.Render(markdown.String()) + if err != nil { + fmt.Println(markdown.String()) + return nil + } + + fmt.Print(strings.TrimLeft(rendered, "\n")) + fmt.Println() + + return nil +} diff --git a/cli/pkg/cli/output/coordinator.go b/cli/pkg/cli/output/coordinator.go new file mode 100644 index 00000000000..672fe0991f9 --- /dev/null +++ b/cli/pkg/cli/output/coordinator.go @@ -0,0 +1,167 @@ +package output + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// SuspendInputMsg tells the input model to suspend and hide +type SuspendInputMsg struct{} + +// ResumeInputMsg tells the input model to resume and show +type ResumeInputMsg struct{} + +// OutputCoordinator manages terminal output and coordinates with interactive input +type OutputCoordinator struct { + mu sync.Mutex + program *tea.Program + inputVisible atomic.Bool + inputModel *InputModel // Reference to current input model for state restoration + restartCallback func(*InputModel) // Callback to restart the program with preserved state +} + +var ( + globalCoordinator *OutputCoordinator + coordinatorMu sync.Mutex +) + +// GetCoordinator returns the global output coordinator instance +func GetCoordinator() *OutputCoordinator { + coordinatorMu.Lock() + defer coordinatorMu.Unlock() + + if globalCoordinator == nil { + globalCoordinator = &OutputCoordinator{} + } + return globalCoordinator +} + +// SetProgram sets the bubbletea program for input coordination +func (oc *OutputCoordinator) SetProgram(program *tea.Program) { + oc.mu.Lock() + defer oc.mu.Unlock() + oc.program = program +} + +// SetInputModel sets the current input model reference for state preservation +func (oc *OutputCoordinator) SetInputModel(model *InputModel) { + oc.mu.Lock() + defer oc.mu.Unlock() + oc.inputModel = model +} + +// SetRestartCallback sets the callback for restarting the program +func (oc *OutputCoordinator) SetRestartCallback(callback func(*InputModel)) { + oc.mu.Lock() + defer oc.mu.Unlock() + oc.restartCallback = callback +} + +// SetInputVisible sets whether input is currently visible +func (oc *OutputCoordinator) SetInputVisible(visible bool) { + oc.inputVisible.Store(visible) +} + +// IsInputVisible returns whether input is currently visible +func (oc *OutputCoordinator) IsInputVisible() bool { + return oc.inputVisible.Load() +} + +// Printf prints formatted output, suspending input if necessary +func (oc *OutputCoordinator) Printf(format string, args ...interface{}) { + oc.mu.Lock() + prog := oc.program + model := oc.inputModel + restart := oc.restartCallback + visible := oc.inputVisible.Load() + oc.mu.Unlock() + + if visible && prog != nil && restart != nil && model != nil { + // Kill/restart approach: completely stop the program, print, restart with state + + // 1. Save the current input state (text, cursor position, etc.) + savedModel := model.Clone() + + // 2. Manually clear the form from terminal BEFORE quitting + clearCodes := model.ClearScreen() + if clearCodes != "" { + fmt.Print(clearCodes) + } + + // 3. Quit the program + prog.Send(Quit()) + + // Small delay to let program actually quit + time.Sleep(20 * time.Millisecond) + + // 4. Print the output + fmt.Printf(format, args...) + + // 5. Restart with preserved state + restart(savedModel) + } else { + // No input showing, just print normally + fmt.Printf(format, args...) + } +} + +// Println prints a line with newline, suspending input if necessary +func (oc *OutputCoordinator) Println(args ...interface{}) { + oc.Printf("%s\n", fmt.Sprint(args...)) +} + +// Print prints output, suspending input if necessary +func (oc *OutputCoordinator) Print(args ...interface{}) { + oc.Printf("%s", fmt.Sprint(args...)) +} + +// Package-level convenience functions + +// Printf prints formatted output via the global coordinator +func Printf(format string, args ...interface{}) { + GetCoordinator().Printf(format, args...) +} + +// Println prints a line with newline via the global coordinator +func Println(args ...interface{}) { + GetCoordinator().Println(args...) +} + +// Print prints output via the global coordinator +func Print(args ...interface{}) { + GetCoordinator().Print(args...) +} + +// SetProgram sets the bubbletea program on the global coordinator +func SetProgram(program *tea.Program) { + GetCoordinator().SetProgram(program) +} + +// SetInputVisible sets input visibility on the global coordinator +func SetInputVisible(visible bool) { + GetCoordinator().SetInputVisible(visible) +} + +// IsInputVisible checks input visibility on the global coordinator +func IsInputVisible() bool { + return GetCoordinator().IsInputVisible() +} + +// SetInputModel sets the input model on the global coordinator +func SetInputModel(model *InputModel) { + GetCoordinator().SetInputModel(model) +} + +// SetRestartCallback sets the restart callback on the global coordinator +func SetRestartCallback(callback func(*InputModel)) { + GetCoordinator().SetRestartCallback(callback) +} + +// Quit returns a Bubble Tea quit message +func Quit() tea.Msg { + return tea.Quit() +} diff --git a/cli/pkg/cli/output/input_model.go b/cli/pkg/cli/output/input_model.go new file mode 100644 index 00000000000..0bd9f3624c2 --- /dev/null +++ b/cli/pkg/cli/output/input_model.go @@ -0,0 +1,497 @@ +package output + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// InputType represents the type of input being collected +type InputType int + +const INPUT_WIDTH = 46 + +const ( + InputTypeMessage InputType = iota + InputTypeApproval + InputTypeFeedback +) + +// InputSubmitMsg is sent when the user submits input +type InputSubmitMsg struct { + Value string + InputType InputType + Approved bool // For approval type + NeedsFeedback bool // For approval type + NoAskAgain bool // For approval type - indicates "don't ask again" was selected +} + +// InputCancelMsg is sent when the user cancels input (Ctrl+C) +type InputCancelMsg struct{} + +// ChangeInputTypeMsg changes the current input type +type ChangeInputTypeMsg struct { + InputType InputType + Title string + Placeholder string +} + +// editorFinishedMsg is sent when the external editor finishes +type editorFinishedMsg struct { + content []byte + err error +} + +// InputModel is the bubbletea model for interactive input +type InputModel struct { + textarea textarea.Model + suspended bool + savedValue string + inputType InputType + title string + placeholder string + currentMode string // "plan" or "act" + width int + lastHeight int // Track height for cleanup on submit + + // For approval type + approvalOptions []string + selectedOption int + pendingApproval bool // Stores approval decision when transitioning to feedback input + + // Styles (huh-inspired theme) + styles fieldStyles +} + +// fieldStyles holds the styling for the input field +type fieldStyles struct { + base lipgloss.Style + title lipgloss.Style + textArea lipgloss.Style + cursor lipgloss.Style + placeholder lipgloss.Style + selector lipgloss.Style + selectedOption lipgloss.Style + option lipgloss.Style +} + +// newFieldStyles creates huh-inspired styles (Charm theme) +func newFieldStyles() fieldStyles { + // Charm theme colors + indigo := lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"} + fuchsia := lipgloss.Color("#F780E2") + normalFg := lipgloss.AdaptiveColor{Light: "235", Dark: "252"} + green := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} + + return fieldStyles{ + base: lipgloss.NewStyle(). + PaddingLeft(1). + BorderStyle(lipgloss.ThickBorder()). + BorderLeft(true). + BorderForeground(lipgloss.Color("238")), + title: lipgloss.NewStyle(). + Foreground(indigo). + Bold(true), + textArea: lipgloss.NewStyle(). + Foreground(normalFg), + cursor: lipgloss.NewStyle(). + Foreground(green), + placeholder: lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"}), + selector: lipgloss.NewStyle(). + Foreground(fuchsia). + SetString("> "), + selectedOption: lipgloss.NewStyle(). + Foreground(normalFg), + option: lipgloss.NewStyle(). + Foreground(normalFg), + } +} + +// NewInputModel creates a new input model +func NewInputModel(inputType InputType, title, placeholder, currentMode string) InputModel { + ta := textarea.New() + ta.Placeholder = placeholder + ta.Focus() + ta.CharLimit = 0 + ta.ShowLineNumbers = false + ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!) + ta.SetHeight(5) + // Don't set width here - let WindowSizeMsg handle it + ta.SetWidth(INPUT_WIDTH) + + // Configure keybindings like huh does: + // alt+enter and ctrl+j for newlines (textarea will handle these) + ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + + // Apply huh-like styling + styles := newFieldStyles() + + // Set cursor color based on mode + cursorColor := lipgloss.Color("3") // Yellow for plan + if currentMode == "act" { + cursorColor = lipgloss.Color("39") // Blue for act + } + + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting + ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling + ta.FocusedStyle.Placeholder = styles.placeholder + ta.FocusedStyle.Text = styles.textArea + ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling + ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor) + ta.Cursor.TextStyle = styles.textArea + + m := InputModel{ + textarea: ta, + inputType: inputType, + title: title, + placeholder: placeholder, + currentMode: currentMode, + width: 0, // Will be set by first WindowSizeMsg + styles: styles, + } + + // For approval type, set up options + if inputType == InputTypeApproval { + m.approvalOptions = []string{ + "Yes", + "Yes, and don't ask again for this task", + "No, with feedback", + } + m.selectedOption = 0 + } + + return m +} + +// Init initializes the model +func (m *InputModel) Init() tea.Cmd { + return textarea.Blink +} + +// Update handles messages +func (m *InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case editorFinishedMsg: + // External editor finished + if msg.err == nil && len(msg.content) > 0 { + m.textarea.SetValue(string(msg.content)) + } + return m, nil + + case SuspendInputMsg: + // Save current value and suspend + m.savedValue = m.textarea.Value() + m.suspended = true + return m, tea.ClearScreen + + case ResumeInputMsg: + // Restore value and resume + m.textarea.SetValue(m.savedValue) + m.suspended = false + return m, nil + + case ChangeInputTypeMsg: + // Change input type (e.g., from approval to feedback) + m.inputType = msg.InputType + m.title = msg.Title + m.placeholder = msg.Placeholder + m.textarea.Placeholder = msg.Placeholder + m.textarea.SetValue("") + m.textarea.Focus() + + if msg.InputType == InputTypeApproval { + m.approvalOptions = []string{ + "Yes", + "Yes, and don't ask again for this task", + "No, with feedback", + } + m.selectedOption = 0 + } + return m, nil + + default: + // Forward all other messages to textarea (including blink ticks) + if !m.suspended && (m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback) { + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + case tea.KeyMsg: + if m.suspended { + return m, nil + } + + // Handle keys for text input types (Message/Feedback) + if m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback { + switch msg.String() { + case "ctrl+c": + return m, func() tea.Msg { return InputCancelMsg{} } + + case "ctrl+e": + // Open external editor (like huh does) + return m, m.openEditor() + + case "enter": + // Intercept enter for submit (textarea handles alt+enter and ctrl+j for newlines) + return m.handleSubmit() + + case "up", "down", "left", "right": + // Let textarea handle navigation + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + // Pass all other keys to textarea (including alt+enter, ctrl+j for newlines) + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd + } + + // Handle keys for approval type + if m.inputType == InputTypeApproval { + switch msg.String() { + case "ctrl+c": + return m, func() tea.Msg { return InputCancelMsg{} } + + case "enter": + return m.handleSubmit() + + case "up": + if m.selectedOption > 0 { + m.selectedOption-- + } + return m, nil + + case "down": + if m.selectedOption < len(m.approvalOptions)-1 { + m.selectedOption++ + } + return m, nil + } + } + } + + return m, nil +} + +// handleSubmit handles submission based on input type +func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) { + switch m.inputType { + case InputTypeMessage: + value := strings.TrimSpace(m.textarea.Value()) + return m, func() tea.Msg { + return InputSubmitMsg{ + Value: value, + InputType: InputTypeMessage, + } + } + + case InputTypeApproval: + selected := m.approvalOptions[m.selectedOption] + approved := strings.HasPrefix(selected, "Yes") + needsFeedback := strings.Contains(selected, "feedback") + noAskAgain := strings.Contains(selected, "don't ask again") + + if needsFeedback { + // Store the approval decision before switching to feedback input + m.pendingApproval = approved + // Switch to feedback input + return m, func() tea.Msg { + return ChangeInputTypeMsg{ + InputType: InputTypeFeedback, + Title: "Your feedback", + Placeholder: "/plan or /act to switch modes\nctrl+e to open editor", + } + } + } + + return m, func() tea.Msg { + return InputSubmitMsg{ + Value: "", + InputType: InputTypeApproval, + Approved: approved, + NeedsFeedback: false, + NoAskAgain: noAskAgain, + } + } + + case InputTypeFeedback: + value := strings.TrimSpace(m.textarea.Value()) + return m, func() tea.Msg { + return InputSubmitMsg{ + Value: value, + InputType: InputTypeFeedback, + Approved: m.pendingApproval, // Pass the stored approval decision + } + } + } + + return m, nil +} + +// View renders the model +func (m *InputModel) View() string { + if m.suspended { + return "" + } + + var parts []string + + // Render title with mode indicator + yellow := lipgloss.Color("3") + blue := lipgloss.Color("39") + + modeStyle := lipgloss.NewStyle().Bold(true) + if m.currentMode == "plan" { + modeStyle = modeStyle.Foreground(yellow) + } else { + modeStyle = modeStyle.Foreground(blue) + } + + modeIndicator := modeStyle.Render(fmt.Sprintf("[%s mode]", m.currentMode)) + titleText := m.styles.title.Render(m.title) + fullTitle := fmt.Sprintf("%s %s", modeIndicator, titleText) + parts = append(parts, fullTitle) + + // Render based on input type + switch m.inputType { + case InputTypeMessage, InputTypeFeedback: + parts = append(parts, m.textarea.View()) + + case InputTypeApproval: + var options []string + for i, option := range m.approvalOptions { + if i == m.selectedOption { + options = append(options, m.styles.selector.Render("")+m.styles.selectedOption.Render(option)) + } else { + options = append(options, " "+m.styles.option.Render(option)) + } + } + parts = append(parts, strings.Join(options, "\n")) + } + + // Wrap everything in the base style with border + content := strings.Join(parts, "\n") + rendered := m.styles.base.Render(content) + + // Add newline before the form (outside the border) + rendered = "\n" + rendered + + // Track height for cleanup + m.lastHeight = lipgloss.Height(rendered) + + return rendered +} + +// ClearScreen returns the ANSI codes to clear the input from the terminal +// This is used when submitting to remove the form cleanly +func (m *InputModel) ClearScreen() string { + if m.lastHeight == 0 { + return "" + } + + // Move cursor up by lastHeight lines and clear from cursor to end of screen + return fmt.Sprintf("\033[%dA\033[J", m.lastHeight) +} + +// Clone creates a deep copy of the InputModel with all state preserved +func (m *InputModel) Clone() *InputModel { + // Create new textarea with same configuration + ta := textarea.New() + ta.SetValue(m.textarea.Value()) + ta.Placeholder = m.placeholder + ta.CharLimit = 0 + ta.ShowLineNumbers = false + ta.Prompt = "" + ta.SetHeight(5) + ta.SetWidth(INPUT_WIDTH) + ta.Focus() + + // Configure keybindings + ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + + // Apply styles (including mode-based cursor color) + cursorColor := lipgloss.Color("3") // Yellow for plan + if m.currentMode == "act" { + cursorColor = lipgloss.Color("39") // Blue for act + } + + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() + ta.FocusedStyle.Placeholder = m.styles.placeholder + ta.FocusedStyle.Text = m.styles.textArea + ta.FocusedStyle.Prompt = lipgloss.NewStyle() + ta.Cursor.Style = lipgloss.NewStyle().Foreground(cursorColor) + ta.Cursor.TextStyle = m.styles.textArea + + // Create cloned model + clone := &InputModel{ + textarea: ta, + suspended: false, // New program starts unsuspended + savedValue: m.savedValue, + inputType: m.inputType, + title: m.title, + placeholder: m.placeholder, + currentMode: m.currentMode, + width: m.width, + lastHeight: m.lastHeight, + approvalOptions: m.approvalOptions, + selectedOption: m.selectedOption, + pendingApproval: m.pendingApproval, // Preserve approval decision + styles: m.styles, + } + + return clone +} + +// openEditor opens an external editor for composing the message +func (m *InputModel) openEditor() tea.Cmd { + // Get editor from environment or use nano as default + editorCmd := "nano" + editorArgs := []string{} + + if editor := os.Getenv("EDITOR"); editor != "" { + editorFields := strings.Fields(editor) + if len(editorFields) > 0 { + editorCmd = editorFields[0] + if len(editorFields) > 1 { + editorArgs = editorFields[1:] + } + } + } + + // Create temp file with current content + tmpFile, err := os.CreateTemp(os.TempDir(), "*.md") + if err != nil { + return func() tea.Msg { + return editorFinishedMsg{err: err} + } + } + + // Write current textarea value to temp file + if err := os.WriteFile(tmpFile.Name(), []byte(m.textarea.Value()), 0o644); err != nil { + return func() tea.Msg { + return editorFinishedMsg{err: err} + } + } + + // Open the editor + cmd := exec.Command(editorCmd, append(editorArgs, tmpFile.Name())...) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + content, readErr := os.ReadFile(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) + + if readErr != nil { + return editorFinishedMsg{err: readErr} + } + + return editorFinishedMsg{content: content, err: err} + }) +} diff --git a/cli/pkg/cli/sqlite/locks.go b/cli/pkg/cli/sqlite/locks.go index 1755e87dee5..d84924c2511 100644 --- a/cli/pkg/cli/sqlite/locks.go +++ b/cli/pkg/cli/sqlite/locks.go @@ -11,7 +11,7 @@ import ( "time" "github.com/cline/cli/pkg/common" - _ "github.com/mattn/go-sqlite3" + _ "github.com/glebarez/go-sqlite" "google.golang.org/grpc/health/grpc_health_v1" ) @@ -60,7 +60,7 @@ func NewLockManager(clineDir string) (*LockManager, error) { } // Database exists - open it normally (no schema creation) - db, err := sql.Open("sqlite3", dbPath) + db, err := sql.Open("sqlite", dbPath) if err != nil { // If we can't open existing database, return nil db manager return &LockManager{dbPath: dbPath, db: nil}, nil @@ -92,7 +92,7 @@ func (lm *LockManager) ensureConnection() error { } // Database exists, try to connect - db, err := sql.Open("sqlite3", lm.dbPath) + db, err := sql.Open("sqlite", lm.dbPath) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) } @@ -160,7 +160,7 @@ func (lm *LockManager) RemoveInstanceLock(address string) error { // HasInstanceAtAddress checks if an instance exists at the given address func (lm *LockManager) HasInstanceAtAddress(address string) (bool, error) { if err := lm.ensureConnection(); err != nil { - return false, nil + return false, err } query := common.CountInstanceLockSQL diff --git a/cli/pkg/cli/task.go b/cli/pkg/cli/task.go index eb31e37397e..6baed67ac54 100644 --- a/cli/pkg/cli/task.go +++ b/cli/pkg/cli/task.go @@ -2,16 +2,33 @@ package cli import ( "context" + "errors" "fmt" "io" "os" + "slices" + "strconv" "strings" + "github.com/cline/cli/pkg/cli/config" "github.com/cline/cli/pkg/cli/global" "github.com/cline/cli/pkg/cli/task" + "github.com/cline/cli/pkg/cli/updater" + "github.com/cline/grpc-go/cline" "github.com/spf13/cobra" ) +// TaskOptions contains options for creating a task +type TaskOptions struct { + Images []string + Files []string + Mode string + Settings []string + Yolo bool + Address string + Verbose bool +} + func NewTaskCommand() *cobra.Command { cmd := &cobra.Command{ Use: "task", @@ -21,12 +38,13 @@ func NewTaskCommand() *cobra.Command { } cmd.AddCommand(newTaskNewCommand()) - cmd.AddCommand(newTaskCancelCommand()) - cmd.AddCommand(newTaskFollowCommand()) + cmd.AddCommand(newTaskPauseCommand()) + cmd.AddCommand(newTaskChatCommand()) cmd.AddCommand(newTaskSendCommand()) cmd.AddCommand(newTaskViewCommand()) cmd.AddCommand(newTaskListCommand()) - cmd.AddCommand(newTaskResumeCommand()) + cmd.AddCommand(newTaskOpenCommand()) + cmd.AddCommand(newTaskRestoreCommand()) return cmd } @@ -47,7 +65,7 @@ func ensureTaskManager(ctx context.Context, address string) error { instanceAddress = address } else { // Ensure default instance exists - if err := ensureDefaultInstance(ctx); err != nil { + if err := global.EnsureDefaultInstance(ctx); err != nil { return fmt.Errorf("failed to ensure default instance: %w", err) } taskManager, err = task.NewManagerForDefault(ctx) @@ -78,38 +96,14 @@ func ensureInstanceAtAddress(ctx context.Context, address string) error { return global.Clients.EnsureInstanceAtAddress(ctx, address) } -// ensureDefaultInstance ensures a default instance exists -func ensureDefaultInstance(ctx context.Context) error { - if global.Clients == nil { - return fmt.Errorf("global clients not initialized") - } - - // Check if we have any instances in the registry - registry := global.Clients.GetRegistry() - if registry.GetDefaultInstance() == "" { - // No default instance, start a new one - instance, err := global.Clients.StartNewInstance(ctx) - if err != nil { - return fmt.Errorf("failed to start new default instance: %w", err) - } - - // Set the new instance as default - if err := registry.SetDefaultInstance(instance.Address); err != nil { - return fmt.Errorf("failed to set default instance: %w", err) - } - } - - return nil -} - func newTaskNewCommand() *cobra.Command { var ( - images []string - files []string - wait bool - workspaces []string - address string - mode string + images []string + files []string + address string + mode string + settings []string + yolo bool ) cmd := &cobra.Command{ @@ -121,6 +115,12 @@ func newTaskNewCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + // Check if an instance exists when no address specified + if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" { + fmt.Println("No instances available for creating tasks") + return nil + } + // Get content from both args and stdin prompt, err := getContentFromStdinAndArgs(args) if err != nil { @@ -142,22 +142,27 @@ func newTaskNewCommand() *cobra.Command { if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil { return fmt.Errorf("failed to set mode: %w", err) } - fmt.Printf("Mode set to: %s\n", mode) + if global.Config.Verbose { + fmt.Printf("Mode set to: %s\n", mode) + } + } + + // Inject yolo_mode_toggled setting if --yolo flag is set + + // Will append to the -s settings to be parsed by the settings parser logic. + // If the yoloMode is also set in the settings, this will override that, since it will be set last. + if yolo { + settings = append(settings, "yolo_mode_toggled=true") } // Create the task - taskID, err := taskManager.CreateTask(ctx, prompt, images, files, workspaces) + taskID, err := taskManager.CreateTask(ctx, prompt, images, files, settings) if err != nil { return fmt.Errorf("failed to create task: %w", err) } - fmt.Printf("Task created successfully with ID: %s\n", taskID) - fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance()) - - // Wait for completion if requested - if wait { - fmt.Println("Following task conversation...") - return taskManager.FollowConversation(ctx) + if global.Config.Verbose { + fmt.Printf("Task created successfully with ID: %s\n", taskID) } return nil @@ -166,21 +171,22 @@ func newTaskNewCommand() *cobra.Command { cmd.Flags().StringSliceVarP(&images, "image", "i", nil, "attach image files") cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files") - cmd.Flags().BoolVar(&wait, "wait", false, "wait for task completion") - cmd.Flags().StringSliceVarP(&workspaces, "workdir", "w", nil, "workdir directory paths") cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)") + cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s aws-region=us-west-2 -s mode=act)") + cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)") + cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)") return cmd } -func newTaskCancelCommand() *cobra.Command { +func newTaskPauseCommand() *cobra.Command { var address string cmd := &cobra.Command{ - Use: "cancel", - Aliases: []string{"c"}, - Short: "Cancel the current task", + Use: "pause", + Aliases: []string{"p"}, + Short: "Pause the current task", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -192,7 +198,7 @@ func newTaskCancelCommand() *cobra.Command { return err } - fmt.Println("Task cancelled successfully") + fmt.Println("Task paused successfully") fmt.Printf("Instance: %s\n", taskManager.GetCurrentInstance()) return nil }, @@ -208,7 +214,9 @@ func newTaskSendCommand() *cobra.Command { files []string address string mode string - approve string + approve bool + deny bool + yolo bool ) cmd := &cobra.Command{ @@ -220,22 +228,28 @@ func newTaskSendCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + // Check if an instance exists when no address specified + if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" { + fmt.Println("No instances available for sending messages") + return nil + } + // Get content from both args and stdin message, err := getContentFromStdinAndArgs(args) if err != nil { return fmt.Errorf("failed to read message: %w", err) } - if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && approve == "" { - return fmt.Errorf("content (message, files, images) required unless using --mode or --approve flags") + if message == "" && len(images) == 0 && len(files) == 0 && mode == "" && !approve && !deny { + return fmt.Errorf("content (message, files, images) required unless using --mode, --approve, or --deny flags") } - if approve != "" && approve != "true" && approve != "false" { - return fmt.Errorf("--approve must be 'true' or 'false'") + if approve && deny { + return fmt.Errorf("cannot use both --approve and --deny flags") } - if approve != "" && mode != "" { - return fmt.Errorf("cannot use --approve and --mode together") + if (approve || deny) && mode != "" { + return fmt.Errorf("cannot use --approve/--deny and --mode together") } // Ensure task manager is initialized @@ -243,15 +257,38 @@ func newTaskSendCommand() *cobra.Command { return err } - sendDisabled, err := taskManager.CheckSendDisabled(ctx) - + // Check if we can send a message + err = taskManager.CheckSendEnabled(ctx) if err != nil { + // Handle specific error cases + if errors.Is(err, task.ErrNoActiveTask) { + fmt.Println("Cannot send message: no active task") + return nil + } + if errors.Is(err, task.ErrTaskBusy) { + fmt.Println("Cannot send message: task is currently busy") + return nil + } + // All other errors are unexpected return fmt.Errorf("failed to check if message can be sent: %w", err) } - if sendDisabled { - fmt.Println("Cannot send message: task is currently busy") - return nil + // Process yolo flag and apply settings + if yolo { + settings := []string{"yolo_mode_toggled=true"} + parsedSettings, secrets, err := task.ParseTaskSettings(settings) + if err != nil { + return fmt.Errorf("failed to parse settings: %w", err) + } + + configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance()) + if err != nil { + return fmt.Errorf("failed to create config manager: %w", err) + } + + if err := configManager.UpdateSettings(ctx, parsedSettings, secrets); err != nil { + return fmt.Errorf("failed to apply settings: %w", err) + } } if mode != "" { @@ -261,7 +298,16 @@ func newTaskSendCommand() *cobra.Command { fmt.Printf("Mode set to %s and message sent successfully.\n", mode) } else { - if err := taskManager.SendMessage(ctx, message, images, files, approve); err != nil { + // Convert approve/deny booleans to string + approveStr := "" + if approve { + approveStr = "true" + } + if deny { + approveStr = "false" + } + + if err := taskManager.SendMessage(ctx, message, images, files, approveStr); err != nil { return err } fmt.Printf("Message sent successfully.\n") @@ -276,19 +322,22 @@ func newTaskSendCommand() *cobra.Command { cmd.Flags().StringSliceVarP(&files, "file", "f", nil, "attach files") cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)") - cmd.Flags().StringVarP(&approve, "approve", "a", "", "approve (true) or deny (false) pending request") + cmd.Flags().BoolVarP(&approve, "approve", "a", false, "approve pending request") + cmd.Flags().BoolVarP(&deny, "deny", "d", false, "deny pending request") + cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)") + cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)") return cmd } -func newTaskFollowCommand() *cobra.Command { +func newTaskChatCommand() *cobra.Command { var address string cmd := &cobra.Command{ - Use: "follow", - Aliases: []string{"f"}, - Short: "Follow current task conversation in real-time", - Long: `Follow the current task conversation, displaying new messages as they arrive in real-time.`, + Use: "chat", + Aliases: []string{"c"}, + Short: "Chat with the current task in interactive mode", + Long: `Chat with the current task, displaying messages in real-time with interactive input enabled.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -297,9 +346,19 @@ func newTaskFollowCommand() *cobra.Command { return err } - fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance()) + // Check if there's an active task before entering follow mode + err := taskManager.CheckSendEnabled(ctx) + if err != nil { + // Handle specific error cases + if errors.Is(err, task.ErrNoActiveTask) { + fmt.Println("No active task found. Use 'cline task new' to create a task first.") + return nil + } + // For other errors (like task busy), we can still enter follow mode + // as the user may want to observe the task + } - return taskManager.FollowConversation(ctx) + return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true) }, } @@ -310,16 +369,16 @@ func newTaskFollowCommand() *cobra.Command { func newTaskViewCommand() *cobra.Command { var ( - current bool - summary bool - address string + follow bool + followComplete bool + address string ) cmd := &cobra.Command{ Use: "view", Aliases: []string{"v"}, Short: "View task conversation", - Long: `Output conversation until next completion, with options for current state or summary only.`, + Long: `Output conversation snapshot by default, or follow with flags.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -330,34 +389,59 @@ func newTaskViewCommand() *cobra.Command { fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance()) - if current { - return taskManager.ShowConversation(ctx) - } else if summary { - return taskManager.GatherFinalSummary(ctx) - } else { + if follow { + // Follow conversation forever (non-interactive) + return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), false) + } else if followComplete { + // Follow until completion return taskManager.FollowConversationUntilCompletion(ctx) + } else { + // Default: show snapshot + return taskManager.ShowConversation(ctx) } }, } - cmd.Flags().BoolVarP(¤t, "current", "c", false, "output current conversation without following") - cmd.Flags().BoolVarP(&summary, "summary", "s", false, "outputs only the completion summary") + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow conversation forever") + cmd.Flags().BoolVarP(&followComplete, "follow-complete", "c", false, "follow until completion") cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") return cmd } func newTaskListCommand() *cobra.Command { - var address string - cmd := &cobra.Command{ Use: "list", Aliases: []string{"l"}, Short: "List recent task history", Long: `Display recent tasks from task history.`, Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Read directly from disk + return task.ListTasksFromDisk() + }, + } + + return cmd +} + +func newTaskOpenCommand() *cobra.Command { + var ( + address string + mode string + settings []string + yolo bool + ) + + cmd := &cobra.Command{ + Use: "open ", + Aliases: []string{"o"}, + Short: "Open a task by ID", + Long: `Open an existing task by ID and optionally update settings or mode.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + taskID := args[0] // Ensure task manager is initialized if err := ensureTaskManager(ctx, address); err != nil { @@ -366,39 +450,128 @@ func newTaskListCommand() *cobra.Command { fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance()) - return taskManager.ListTasks(ctx) + // Resume the task + if err := taskManager.ResumeTask(ctx, taskID); err != nil { + return err + } + + // Apply mode if provided + if mode != "" { + if err := taskManager.SetMode(ctx, mode, nil, nil, nil); err != nil { + return fmt.Errorf("failed to set mode: %w", err) + } + if global.Config.Verbose { + fmt.Printf("Mode set to: %s\n", mode) + } + } + + // Process yolo flag and apply settings + if yolo { + settings = append(settings, "yolo_mode_toggled=true") + } + + if len(settings) > 0 { + // Parse settings using existing parser + parsedSettings, secrets, err := task.ParseTaskSettings(settings) + if err != nil { + return fmt.Errorf("failed to parse settings: %w", err) + } + + // Apply task-specific settings using UpdateTaskSettings RPC + if parsedSettings != nil { + _, err = taskManager.GetClient().State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{ + Settings: parsedSettings, + TaskId: &taskID, + }) + if err != nil { + return fmt.Errorf("failed to apply task settings: %w", err) + } + if global.Config.Verbose { + fmt.Println("Task-specific settings applied successfully") + } + } + + // Handle secrets separately if provided (they must go to global config) + if secrets != nil { + // Secrets are always global, not task-specific + configManager, err := config.NewManager(ctx, taskManager.GetCurrentInstance()) + if err != nil { + return fmt.Errorf("failed to create config manager: %w", err) + } + + if err := configManager.UpdateSettings(ctx, nil, secrets); err != nil { + return fmt.Errorf("failed to apply secrets: %w", err) + } + if global.Config.Verbose { + fmt.Println("Global secrets applied successfully") + } + } + } + + return nil }, } cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") + cmd.Flags().StringVarP(&mode, "mode", "m", "", "mode (act|plan)") + cmd.Flags().StringSliceVarP(&settings, "setting", "s", nil, "task settings (key=value format, e.g., -s model=claude)") + cmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)") + cmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)") + return cmd } -func newTaskResumeCommand() *cobra.Command { - var address string +func newTaskRestoreCommand() *cobra.Command { + var ( + restoreType string + address string + ) cmd := &cobra.Command{ - Use: "resume ", - Aliases: []string{"r"}, - Short: "Resume a task by ID", - Long: `Resume an existing task by ID.`, - Args: cobra.ExactArgs(1), + Use: "restore ", + Short: "Restore task to a specific checkpoint", + Long: `Restore the current task to a specific checkpoint by checkpoint ID (timestamp) and by type.`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - taskID := args[0] + checkpointID := args[0] + + // Convert checkpoint ID string to int64 + id, err := strconv.ParseInt(checkpointID, 10, 64) + if err != nil { + return fmt.Errorf("invalid checkpoint ID '%s': must be a valid number", checkpointID) + } + + validTypes := []string{"task", "workspace", "taskAndWorkspace"} + if !slices.Contains(validTypes, restoreType) { + return fmt.Errorf("invalid restore type '%s': must be one of [task, workspace, taskAndWorkspace]", restoreType) + } // Ensure task manager is initialized if err := ensureTaskManager(ctx, address); err != nil { return err } + // Validate checkpoint exists before attempting restore + if err := taskManager.ValidateCheckpointExists(ctx, id); err != nil { + return err + } + fmt.Printf("Using instance: %s\n", taskManager.GetCurrentInstance()) + fmt.Printf("Restoring to checkpoint %d (type: %s)\n", id, restoreType) + + if err := taskManager.RestoreCheckpoint(ctx, id, restoreType); err != nil { + return fmt.Errorf("failed to restore checkpoint: %w", err) + } - return taskManager.ResumeTask(ctx, taskID) + fmt.Println("Checkpoint restored successfully") + return nil }, } + cmd.Flags().StringVarP(&restoreType, "type", "t", "task", "Restore type (task, workspace, taskAndWorkspace)") cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use") + return cmd } @@ -419,17 +592,20 @@ func getContentFromStdinAndArgs(args []string) (string, error) { // Check if data is being piped to stdin if (stat.Mode() & os.ModeCharDevice) == 0 { - stdinBytes, err := io.ReadAll(os.Stdin) - if err != nil { - return "", fmt.Errorf("failed to read from stdin: %w", err) - } + // 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(" ") + stdinContent := strings.TrimSpace(string(stdinBytes)) + if stdinContent != "" { + if content.Len() > 0 { + content.WriteString(" ") + } + content.WriteString(stdinContent) } - content.WriteString(stdinContent) } } @@ -442,3 +618,58 @@ func CleanupTaskManager() { taskManager.Cleanup() } } + +// NewTaskManagerForAddress is an exported wrapper around task.NewManagerForAddress +func NewTaskManagerForAddress(ctx context.Context, address string) (*task.Manager, error) { + return task.NewManagerForAddress(ctx, address) +} + +// CreateAndFollowTask creates a new task and immediately follows it in interactive mode +// This is used by the root command to provide a streamlined UX +func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) error { + // Initialize task manager with the provided instance address + if err := ensureTaskManager(ctx, opts.Address); err != nil { + return err + } + + // Set mode to plan by default if not specified + if opts.Mode == "" { + opts.Mode = "plan" + } + + // Set mode if provided + if opts.Mode != "" { + if err := taskManager.SetMode(ctx, opts.Mode, nil, nil, nil); err != nil { + return fmt.Errorf("failed to set mode: %w", err) + } + if global.Config.Verbose { + fmt.Printf("Mode set to: %s\n", opts.Mode) + } + } + + // Inject yolo_mode_toggled setting if --yolo flag is set + if opts.Yolo { + opts.Settings = append(opts.Settings, "yolo_mode_toggled=true") + } + + // Create the task + taskID, err := taskManager.CreateTask(ctx, prompt, opts.Images, opts.Files, opts.Settings) + if err != nil { + return fmt.Errorf("failed to create task: %w", err) + } + + if global.Config.Verbose { + fmt.Printf("Task created successfully with ID: %s\n\n", taskID) + } + + // Check for updates in background after task is created + updater.CheckAndUpdate(opts.Verbose) + + // If yolo mode is enabled, follow until completion (non-interactive) + // Otherwise, follow in interactive mode + if opts.Yolo { + return taskManager.FollowConversationUntilCompletion(ctx) + } else { + return taskManager.FollowConversation(ctx, taskManager.GetCurrentInstance(), true) + } +} diff --git a/cli/pkg/cli/task/history_handler.go b/cli/pkg/cli/task/history_handler.go new file mode 100644 index 00000000000..0ce8835bd20 --- /dev/null +++ b/cli/pkg/cli/task/history_handler.go @@ -0,0 +1,72 @@ +package task + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/cline/cli/pkg/cli/display" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/types" + "github.com/cline/grpc-go/cline" +) + +// ListTasksFromDisk reads task history directly from disk +func ListTasksFromDisk() error { + // Get the task history file path + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + filePath := filepath.Join(homeDir, ".cline", "data", "state", "taskHistory.json") + + // Read the file + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("No task history found.") + return nil + } + return fmt.Errorf("failed to read task history: %w", err) + } + + // Parse JSON into intermediate struct + var historyItems []types.HistoryItem + if err := json.Unmarshal(data, &historyItems); err != nil { + return fmt.Errorf("failed to parse task history: %w", err) + } + + if len(historyItems) == 0 { + fmt.Println("No task history found.") + return nil + } + + // Sort by timestamp ascending (oldest first, newest last) + sort.Slice(historyItems, func(i, j int) bool { + return historyItems[i].Ts < historyItems[j].Ts + }) + + // Convert to protobuf TaskItem format for rendering + tasks := make([]*cline.TaskItem, len(historyItems)) + for i, item := range historyItems { + tasks[i] = &cline.TaskItem{ + Id: item.Id, + Task: item.Task, + Ts: item.Ts, + IsFavorited: item.IsFavorited, + Size: item.Size, + TotalCost: item.TotalCost, + TokensIn: item.TokensIn, + TokensOut: item.TokensOut, + CacheWrites: item.CacheWrites, + CacheReads: item.CacheReads, + } + } + + // Use existing renderer + renderer := display.NewRenderer(global.Config.OutputFormat) + return renderer.RenderTaskList(tasks) +} diff --git a/cli/pkg/cli/task/input_handler.go b/cli/pkg/cli/task/input_handler.go new file mode 100644 index 00000000000..4dd99f39a69 --- /dev/null +++ b/cli/pkg/cli/task/input_handler.go @@ -0,0 +1,575 @@ +package task + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/cline/cli/pkg/cli/global" + "github.com/cline/cli/pkg/cli/output" + "github.com/cline/cli/pkg/cli/types" +) + +// InputHandler manages interactive user input during follow mode +type InputHandler struct { + manager *Manager + coordinator *StreamCoordinator + cancelFunc context.CancelFunc + mu sync.RWMutex + isRunning bool + pollTicker *time.Ticker + program *tea.Program + programRunning bool + programDoneChan chan struct{} // Signals when program actually exits + resultChan chan output.InputSubmitMsg + cancelChan chan struct{} + feedbackApproval bool // Track if we're in feedback after approval + feedbackApproved bool // Track the approval decision + approvalMessage *types.ClineMessage // Store the approval message for determining action + ctx context.Context // Context for restart callback +} + +// NewInputHandler creates a new input handler +func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler { + return &InputHandler{ + manager: manager, + coordinator: coordinator, + cancelFunc: cancelFunc, + isRunning: false, + pollTicker: time.NewTicker(500 * time.Millisecond), + resultChan: make(chan output.InputSubmitMsg, 1), + cancelChan: make(chan struct{}, 1), + } +} + +// Start begins monitoring for input opportunities +func (ih *InputHandler) Start(ctx context.Context, errChan chan error) { + ih.mu.Lock() + ih.isRunning = true + ih.mu.Unlock() + + defer func() { + ih.mu.Lock() + ih.isRunning = false + ih.mu.Unlock() + ih.pollTicker.Stop() + if ih.program != nil { + ih.program.Quit() + } + }() + + for { + select { + case <-ctx.Done(): + return + case <-ih.pollTicker.C: + // First check if approval is needed + needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx) + if err != nil { + if global.Config.Verbose { + output.Printf("\nDebug: CheckNeedsApproval error: %v\n", err) + } + continue + } + + if needsApproval { + ih.coordinator.SetInputAllowed(true) + + // Show approval prompt + approved, feedback, err := ih.promptForApproval(ctx, approvalMsg) + + if err != nil { + // Check if the error is due to interrupt (Ctrl+C) or context cancellation + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + // User pressed Ctrl+C - cancel context to exit FollowConversation + ih.cancelFunc() + return + } + if global.Config.Verbose { + output.Printf("\nDebug: Approval prompt error: %v\n", err) + } + continue + } + + ih.coordinator.SetInputAllowed(false) + + // Send approval response + approveStr := "false" + if approved { + approveStr = "true" + } + + if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil { + output.Printf("\nError sending approval: %v\n", err) + continue + } + + if global.Config.Verbose { + output.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback) + } + + // Give the system a moment to process before re-polling + time.Sleep(1 * time.Second) + continue + } + + // Check if we can send a regular message + err = ih.manager.CheckSendEnabled(ctx) + if err != nil { + // Handle specific error cases + if errors.Is(err, ErrNoActiveTask) { + // No active task - don't show input prompt + ih.coordinator.SetInputAllowed(false) + continue + } + if errors.Is(err, ErrTaskBusy) { + // Task is busy - don't show input prompt + ih.coordinator.SetInputAllowed(false) + continue + } + // Unexpected error + if global.Config.Verbose { + output.Printf("\nDebug: CheckSendEnabled error: %v\n", err) + } + continue + } + + // If we reach here, we can send a message + ih.coordinator.SetInputAllowed(true) + + // Show prompt and get input + message, shouldSend, err := ih.promptForInput(ctx) + + if err != nil { + // Check if the error is due to interrupt (Ctrl+C) or context cancellation + if errors.Is(err, context.Canceled) || ctx.Err() != nil { + // User pressed Ctrl+C - cancel context to exit FollowConversation + ih.cancelFunc() + return + } + if global.Config.Verbose { + output.Printf("\nDebug: Input prompt error: %v\n", err) + } + continue + } + + ih.coordinator.SetInputAllowed(false) + + if shouldSend { + // Check for mode switch commands first + newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message) + if isModeSwitch { + // Create styles for mode switch messages (respect global color profile) + actStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + planStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true) + + if remainingMessage != "" { + // Switching with a message - behavior differs by mode + if newMode == "act" { + // Act mode: can send mode + message in one call + if err := ih.manager.SetMode(ctx, newMode, &remainingMessage, nil, nil); err != nil { + output.Printf("\nError switching to act mode with message: %v\n", err) + continue + } + output.Printf("\n%s\n", actStyle.Render("Switched to act mode")) + } else { + // Plan mode: must switch first, then send message separately + if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil { + output.Printf("\nError switching to plan mode: %v\n", err) + continue + } + output.Printf("\n%s\n", planStyle.Render("Switched to plan mode")) + + // Now send the message separately + time.Sleep(500 * time.Millisecond) // Give mode switch time to process + if err := ih.manager.SendMessage(ctx, remainingMessage, nil, nil, ""); err != nil { + output.Printf("\nError sending message after mode switch: %v\n", err) + continue + } + } + } else { + // Just switch mode, no message + if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil { + output.Printf("\nError switching to %s mode: %v\n", newMode, err) + continue + } + // Color based on mode + if newMode == "act" { + output.Printf("\n%s\n", actStyle.Render("Switched to act mode")) + } else { + output.Printf("\n%s\n", planStyle.Render("Switched to plan mode")) + } + } + + // Mode switch handled, continue to next poll + time.Sleep(1 * time.Second) + continue + } + + // Handle special commands + if handled := ih.handleSpecialCommand(ctx, message); handled { + continue + } + + // Send the message + if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil { + output.Printf("\nError sending message: %v\n", err) + continue + } + + if global.Config.Verbose { + output.Printf("\nDebug: Message sent successfully\n") + } + + // Give the system a moment to process before re-polling + time.Sleep(1 * time.Second) + } + } + } +} + +// determineAutoApprovalAction determines which auto-approval action to enable based on the ask type +func determineAutoApprovalAction(msg *types.ClineMessage) (string, error) { + switch types.AskType(msg.Ask) { + case types.AskTypeTool: + // Parse tool message to determine if it's a read or edit operation + var toolMsg types.ToolMessage + if err := json.Unmarshal([]byte(msg.Text), &toolMsg); err != nil { + return "", fmt.Errorf("failed to parse tool message: %w", err) + } + + // Determine action based on tool type + switch types.ToolType(toolMsg.Tool) { + case types.ToolTypeReadFile, + types.ToolTypeListFilesTopLevel, + types.ToolTypeListFilesRecursive, + types.ToolTypeListCodeDefinitionNames, + types.ToolTypeSearchFiles, + types.ToolTypeWebFetch: + return "read_files", nil + case types.ToolTypeEditedExistingFile, + types.ToolTypeNewFileCreated: + return "edit_files", nil + default: + return "", fmt.Errorf("unsupported tool type: %s", toolMsg.Tool) + } + + case types.AskTypeCommand: + return "execute_all_commands", nil + + case types.AskTypeBrowserActionLaunch: + return "use_browser", nil + + case types.AskTypeUseMcpServer: + return "use_mcp", nil + + default: + return "", fmt.Errorf("unsupported ask type: %s", msg.Ask) + } +} + +// promptForInput displays an interactive prompt and waits for user input +func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) { + currentMode := ih.manager.GetCurrentMode() + + model := output.NewInputModel( + output.InputTypeMessage, + "Cline is ready for your message...", + "/plan or /act to switch modes\nctrl+e to open editor", + currentMode, + ) + + return ih.runInputProgram(ctx, model) +} + +// promptForApproval displays an approval prompt for tool/command requests +func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) { + // Store the approval message for later use in determining auto-approval action + ih.approvalMessage = msg + + model := output.NewInputModel( + output.InputTypeApproval, + "Let Cline use this tool?", + "", + ih.manager.GetCurrentMode(), + ) + + message, shouldSend, err := ih.runInputProgram(ctx, model) + if err != nil { + return false, "", err + } + + if !shouldSend { + return false, "", nil + } + + // The approval and feedback are handled via the model state + return ih.feedbackApproved, message, nil +} + +// runInputProgram runs the bubbletea program and waits for result +func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputModel) (string, bool, error) { + ih.mu.Lock() + + // Create the program with custom update wrapper + wrappedModel := &inputProgramWrapper{ + model: &model, + resultChan: ih.resultChan, + cancelChan: ih.cancelChan, + handler: ih, + } + + ih.program = tea.NewProgram(wrappedModel) + ih.programDoneChan = make(chan struct{}) + ih.ctx = ctx + + // Set up coordinator references + output.SetProgram(ih.program) + output.SetInputModel(wrappedModel.model) + output.SetRestartCallback(ih.restartProgram) + output.SetInputVisible(true) + ih.programRunning = true + ih.mu.Unlock() + + // Run program in goroutine + programErrChan := make(chan error, 1) + go func() { + if _, err := ih.program.Run(); err != nil { + programErrChan <- err + } + // Signal that program is done + close(ih.programDoneChan) + }() + + // Wait for result, cancellation, or context done + select { + case <-ctx.Done(): + ih.mu.Lock() + output.SetInputVisible(false) + if ih.program != nil { + ih.program.Quit() + } + ih.programRunning = false + ih.mu.Unlock() + return "", false, ctx.Err() + + case <-ih.cancelChan: + ih.mu.Lock() + output.SetInputVisible(false) + ih.programRunning = false + ih.mu.Unlock() + return "", false, context.Canceled + + case err := <-programErrChan: + ih.mu.Lock() + output.SetInputVisible(false) + ih.programRunning = false + ih.mu.Unlock() + return "", false, err + + case result := <-ih.resultChan: + ih.mu.Lock() + output.SetInputVisible(false) + ih.programRunning = false + ih.mu.Unlock() + + // Handle different input types + switch result.InputType { + case output.InputTypeMessage: + if result.Value == "" { + return "", false, nil + } + return result.Value, true, nil + + case output.InputTypeApproval: + if result.NeedsFeedback { + // Need to collect feedback - will be handled by model state change + return "", false, nil + } + + // Check if NoAskAgain was selected + if result.NoAskAgain && result.Approved && ih.approvalMessage != nil { + // Determine which auto-approval action to enable + action, err := determineAutoApprovalAction(ih.approvalMessage) + if err != nil { + output.Printf("\nWarning: Could not determine auto-approval action: %v\n", err) + } else { + // Enable the auto-approval action + if err := ih.manager.UpdateTaskAutoApprovalAction(ctx, action); err != nil { + output.Printf("\nWarning: Could not update auto-approval: %v\n", err) + } else { + output.Printf("\nAuto-approval enabled for %s\n", action) + } + } + } + + // Store approval state for when feedback comes back + ih.feedbackApproval = false + ih.feedbackApproved = result.Approved + return "", true, nil + + case output.InputTypeFeedback: + // This came from approval flow + ih.feedbackApproval = true + ih.feedbackApproved = result.Approved // Use the approval decision from the feedback + return result.Value, true, nil + } + + return "", false, nil + } +} + +// inputProgramWrapper wraps the InputModel to handle message routing +type inputProgramWrapper struct { + model *output.InputModel + resultChan chan output.InputSubmitMsg + cancelChan chan struct{} + handler *InputHandler +} + +func (w *inputProgramWrapper) Init() tea.Cmd { + return w.model.Init() +} + +func (w *inputProgramWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case output.InputSubmitMsg: + // Handle input submission - clear the screen before quitting + w.resultChan <- msg + clearCodes := w.model.ClearScreen() + if clearCodes != "" { + fmt.Print(clearCodes) + } + return w, tea.Quit + + case output.InputCancelMsg: + // Handle cancellation - clear the screen before quitting + w.cancelChan <- struct{}{} + clearCodes := w.model.ClearScreen() + if clearCodes != "" { + fmt.Print(clearCodes) + } + return w, tea.Quit + + case output.ChangeInputTypeMsg: + // Change input type (approval -> feedback) + _, cmd := w.model.Update(msg) + return w, cmd + } + + // Forward to wrapped model + _, cmd := w.model.Update(msg) + return w, cmd +} + +func (w *inputProgramWrapper) View() string { + return w.model.View() +} + +// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message +func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) { + trimmed := strings.TrimSpace(message) + lower := strings.ToLower(trimmed) + + if strings.HasPrefix(lower, "/plan") { + remaining := strings.TrimSpace(trimmed[5:]) + return "plan", remaining, true + } + + if strings.HasPrefix(lower, "/act") { + remaining := strings.TrimSpace(trimmed[4:]) + return "act", remaining, true + } + + return "", message, false +} + +// handleSpecialCommand processes special commands like /cancel, /exit +func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool { + switch strings.ToLower(strings.TrimSpace(message)) { + case "/cancel": + ih.manager.GetRenderer().RenderTaskCancelled() + if err := ih.manager.CancelTask(ctx); err != nil { + output.Printf("Error cancelling task: %v\n", err) + } else { + output.Println("Task cancelled successfully") + } + return true + case "/exit", "/quit": + output.Println("\nExiting follow mode...") + return true + default: + return false + } +} + +// Stop stops the input handler +func (ih *InputHandler) Stop() { + ih.mu.Lock() + defer ih.mu.Unlock() + if ih.pollTicker != nil { + ih.pollTicker.Stop() + } + if ih.program != nil && ih.programRunning { + ih.program.Quit() + } + ih.isRunning = false +} + +// IsRunning returns whether the input handler is currently running +func (ih *InputHandler) IsRunning() bool { + ih.mu.RLock() + defer ih.mu.RUnlock() + return ih.isRunning +} + +// restartProgram restarts the Bubble Tea program with preserved state +func (ih *InputHandler) restartProgram(savedModel *output.InputModel) { + ih.mu.Lock() + + // Wait for old program to actually quit + if ih.programDoneChan != nil { + select { + case <-ih.programDoneChan: + // Program quit successfully + case <-time.After(100 * time.Millisecond): + // Timeout - continue anyway + } + } + + // Create new wrapper with the saved model + wrappedModel := &inputProgramWrapper{ + model: savedModel, + resultChan: ih.resultChan, + cancelChan: ih.cancelChan, + handler: ih, + } + + // Start new program + ih.program = tea.NewProgram(wrappedModel) + ih.programDoneChan = make(chan struct{}) + + // Update coordinator references + output.SetProgram(ih.program) + output.SetInputModel(savedModel) + output.SetInputVisible(true) + ih.programRunning = true + ih.mu.Unlock() + + // Run in goroutine + go func() { + if _, err := ih.program.Run(); err != nil { + // Log error if needed + if global.Config.Verbose { + output.Printf("\nDebug: Program restart error: %v\n", err) + } + } + close(ih.programDoneChan) + }() +} diff --git a/cli/pkg/cli/task/manager.go b/cli/pkg/cli/task/manager.go index fe88286b600..ebd552d76e8 100644 --- a/cli/pkg/cli/task/manager.go +++ b/cli/pkg/cli/task/manager.go @@ -3,8 +3,12 @@ package task import ( "context" "encoding/json" + "errors" "fmt" + "os" + "os/signal" "sync" + "syscall" "time" "github.com/cline/cli/pkg/cli/display" @@ -15,6 +19,12 @@ import ( "github.com/cline/grpc-go/cline" ) +// Sentinel errors for CheckSendEnabled +var ( + ErrNoActiveTask = fmt.Errorf("no active task") + ErrTaskBusy = fmt.Errorf("task is currently busy") +) + // Manager handles task execution and message display type Manager struct { mu sync.RWMutex @@ -22,14 +32,21 @@ type Manager struct { clientAddress string state *types.ConversationState renderer *display.Renderer + toolRenderer *display.ToolRenderer + systemRenderer *display.SystemMessageRenderer streamingDisplay *display.StreamingDisplay handlerRegistry *handlers.HandlerRegistry + isStreamingMode bool + isInteractive bool + currentMode string // "plan" or "act" } // NewManager creates a new task manager func NewManager(client *client.ClineClient) *Manager { state := types.NewConversationState() - renderer := display.NewRenderer() + renderer := display.NewRenderer(global.Config.OutputFormat) + toolRenderer := display.NewToolRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat) + systemRenderer := display.NewSystemMessageRenderer(renderer, renderer.GetMdRenderer(), global.Config.OutputFormat) streamingDisplay := display.NewStreamingDisplay(state, renderer) // Create handler registry and register handlers @@ -42,8 +59,11 @@ func NewManager(client *client.ClineClient) *Manager { clientAddress: "", // Will be set when client is provided state: state, renderer: renderer, + toolRenderer: toolRenderer, + systemRenderer: systemRenderer, streamingDisplay: streamingDisplay, handlerRegistry: registry, + currentMode: "plan", // Default mode } } @@ -106,7 +126,7 @@ func (m *Manager) GetCurrentInstance() string { } // CreateTask creates a new task -func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, workspacePaths []string) (string, error) { +func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, settingsFlags []string) (string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -118,8 +138,8 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [ if len(images) > 0 { m.renderer.RenderDebug("Images: %v", images) } - if len(workspacePaths) > 0 { - m.renderer.RenderDebug("Workspaces: %v", workspacePaths) + if len(settingsFlags) > 0 { + m.renderer.RenderDebug("Settings: %v", settingsFlags) } } @@ -128,11 +148,22 @@ func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files [ return "", fmt.Errorf("failed to cancel existing task: %w", err) } + // Parse task settings if provided + var taskSettings *cline.Settings + if len(settingsFlags) > 0 { + var err error + taskSettings, _, err = ParseTaskSettings(settingsFlags) + if err != nil { + return "", fmt.Errorf("failed to parse task settings: %w", err) + } + } + // Create task request req := &cline.NewTaskRequest{ - Text: prompt, - Images: images, - Files: files, + Text: prompt, + Images: images, + Files: files, + TaskSettings: taskSettings, } resp, err := m.client.Task.NewTask(ctx, req) @@ -184,26 +215,64 @@ func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error { fmt.Println("Cancelled existing task to start new one") } } - } + } return nil } -// CheckSendDisabled determines if we can send a message to the current task +// ValidateCheckpointExists checks if a checkpoint ID is valid +func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int64) error { + // Get current state + state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return fmt.Errorf("failed to get state: %w", err) + } + + // Extract messages + messages, err := m.extractMessagesFromState(state.StateJson) + if err != nil { + return fmt.Errorf("failed to extract messages: %w", err) + } + + // Find and validate the checkpoint message + for _, msg := range messages { + if msg.Timestamp == checkpointID { + if msg.Say != string(types.SayTypeCheckpointCreated) { + return fmt.Errorf("timestamp %d is not a checkpoint (type: %s)", checkpointID, msg.Type) + } + return nil // Valid checkpoint + } + } + + return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID) +} + +// CheckSendEnabled checks if we can send a message to the current task +// Returns nil if sending is allowed, or an error indicating why it's not allowed // We duplicate the logic from buttonConfig::getButtonConfig -func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) { +func (m *Manager) CheckSendEnabled(ctx context.Context) error { state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) if err != nil { - return false, fmt.Errorf("failed to get latest state: %w", err) + return fmt.Errorf("failed to get latest state: %w", err) + } + + var stateData types.ExtensionState + if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil { + return fmt.Errorf("failed to parse state: %w", err) + } + + // Check if there is an active task + if stateData.CurrentTaskItem == nil { + return ErrNoActiveTask } messages, err := m.extractMessagesFromState(state.StateJson) if err != nil { - return false, fmt.Errorf("failed to extract messages: %w", err) + return fmt.Errorf("failed to extract messages: %w", err) } if len(messages) == 0 { - return false, nil + return nil } // Use final message to perform validation @@ -233,15 +302,25 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) { if global.Config.Verbose { m.renderer.RenderDebug("Send disabled: task is streaming and non-error") } - return true, nil + return ErrTaskBusy } - // All ask messages allow sending + // All ask messages allow sending, EXCEPT command_output if lastMessage.Type == types.MessageTypeAsk { + // Special case: command_output means command is actively streaming + // In the CLI, we don't want to show input during streaming output (too messy) + // The webview can show "Proceed While Running" button, but CLI should wait + if lastMessage.Ask == string(types.AskTypeCommandOutput) { + if global.Config.Verbose { + m.renderer.RenderDebug("Send disabled: command output is streaming") + } + return ErrTaskBusy + } + if global.Config.Verbose { m.renderer.RenderDebug("Send enabled: ask message") } - return false, nil + return nil } // Technically unnecessary but implements getButtonConfig 1-1 @@ -249,14 +328,58 @@ func (m *Manager) CheckSendDisabled(ctx context.Context) (bool, error) { if global.Config.Verbose { m.renderer.RenderDebug("Send disabled: API request is active") } - return true, nil + return ErrTaskBusy } if global.Config.Verbose { m.renderer.RenderDebug("Send disabled: default fallback") } - return true, nil + return ErrTaskBusy +} + +// CheckNeedsApproval determines if the current task is waiting for approval +// Returns (needsApproval, lastMessage, error) +func (m *Manager) CheckNeedsApproval(ctx context.Context) (bool, *types.ClineMessage, error) { + state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) + if err != nil { + return false, nil, fmt.Errorf("failed to get latest state: %w", err) + } + + messages, err := m.extractMessagesFromState(state.StateJson) + if err != nil { + return false, nil, fmt.Errorf("failed to extract messages: %w", err) + } + + if len(messages) == 0 { + return false, nil, nil + } + + // Use final message to check if approval is needed + lastMessage := messages[len(messages)-1] + + // Only check non-partial ask messages + if lastMessage.Partial { + return false, nil, nil + } + + // Check if this is an approval-required ask type + if lastMessage.Type == types.MessageTypeAsk { + approvalTypes := []string{ + string(types.AskTypeTool), + string(types.AskTypeCommand), + string(types.AskTypeBrowserActionLaunch), + string(types.AskTypeUseMcpServer), + } + + for _, approvalType := range approvalTypes { + if lastMessage.Ask == approvalType { + return true, lastMessage, nil + } + } + } + + return false, nil, nil } // SendMessage sends a followup message to the current task @@ -449,66 +572,35 @@ func (m *Manager) ResumeTask(ctx context.Context, taskID string) error { return nil } -// CancelTask cancels the current task -func (m *Manager) CancelTask(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - - _, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to cancel task: %w", err) +// RestoreCheckpoint restores the task to a specific checkpoint +func (m *Manager) RestoreCheckpoint(ctx context.Context, checkpointID int64, restoreType string) error { + if global.Config.Verbose { + m.renderer.RenderDebug("Restoring checkpoint: %d (type: %s)", checkpointID, restoreType) } - return nil -} - -// ListTasks retrieves and displays task history -func (m *Manager) ListTasks(ctx context.Context) error { - m.mu.RLock() - defer m.mu.RUnlock() - - req := &cline.GetTaskHistoryRequest{ - FavoritesOnly: false, - SearchQuery: "", - SortBy: "oldest", - CurrentWorkspaceOnly: false, + // Create the checkpoint restore request + req := &cline.CheckpointRestoreRequest{ + Metadata: &cline.Metadata{}, + Number: checkpointID, + RestoreType: restoreType, } - resp, err := m.client.Task.GetTaskHistory(ctx, req) + _, err := m.client.Checkpoints.CheckpointRestore(ctx, req) if err != nil { - return fmt.Errorf("failed to get task history: %w", err) - } - - if len(resp.Tasks) == 0 { - fmt.Println("No task history found.") - return nil + return fmt.Errorf("failed to restore checkpoint %d: %w", checkpointID, err) } - return m.renderer.RenderTaskList(resp.Tasks) + return nil } -// GatherFinalSummary attempts to gather the latest completion_result output and display it -func (m *Manager) GatherFinalSummary(ctx context.Context) error { - m.mu.RLock() - defer m.mu.RUnlock() - - state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) - if err != nil { - return fmt.Errorf("failed to get state: %w", err) - } +// CancelTask cancels the current task +func (m *Manager) CancelTask(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() - messages, err := m.extractMessagesFromState(state.StateJson) + _, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{}) if err != nil { - return fmt.Errorf("failed to extract messages: %w", err) - } - - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - - // Check if this is a completion result SAY message - if msg.IsSay() && msg.Say == string(types.SayTypeCompletionResult) { - return m.displayMessage(msg, false, false, i) - } + return fmt.Errorf("failed to cancel task: %w", err) } return nil @@ -516,6 +608,22 @@ func (m *Manager) GatherFinalSummary(ctx context.Context) error { // ShowConversation displays the current conversation func (m *Manager) ShowConversation(ctx context.Context) error { + // Check if there's an active task before showing conversation + err := m.CheckSendEnabled(ctx) + if err != nil { + // Handle specific error cases + if errors.Is(err, ErrNoActiveTask) { + fmt.Println("No active task found. Use 'cline task new' to create a task first.") + return nil + } + // For other errors (like task busy), we can still show the conversation + } + + // Disable streaming mode for static view + m.mu.Lock() + m.isStreamingMode = false + m.mu.Unlock() + m.mu.RLock() defer m.mu.RUnlock() @@ -536,16 +644,35 @@ func (m *Manager) ShowConversation(ctx context.Context) error { return nil } - // Display messages for i, msg := range messages { + if msg.Partial { + continue + } m.displayMessage(msg, false, false, i) } return nil } -func (m *Manager) FollowConversation(ctx context.Context) error { - fmt.Println("Following task conversation... (Press Ctrl+C to exit)") +func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string, interactive bool) error { + // Enable streaming mode + m.mu.Lock() + m.isStreamingMode = true + m.isInteractive = interactive + m.mu.Unlock() + + if global.Config.OutputFormat != "plain" { + markdown := fmt.Sprintf("*Using instance: %s*\n*Press Ctrl+C to exit*", instanceAddress) + rendered := m.renderer.RenderMarkdown(markdown) + fmt.Printf("%s", rendered) + } else { + fmt.Printf("Using instance: %s\n", instanceAddress) + if interactive { + fmt.Println("Following task conversation in interactive mode... (Press Ctrl+C to exit)") + } else { + fmt.Println("Following task conversation... (Press Ctrl+C to exit)") + } + } ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -561,21 +688,64 @@ func (m *Manager) FollowConversation(ctx context.Context) error { } coordinator.SetConversationTurnStartIndex(totalMessageCount) - fmt.Println("\n--- Live updates ---") - // Start both streams concurrently - errChan := make(chan error, 2) + errChan := make(chan error, 3) if global.Config.OutputFormat == "json" { go m.handleStateStream(ctx, coordinator, errChan, nil) } else { go m.handleStateStream(ctx, coordinator, errChan, nil) go m.handlePartialMessageStream(ctx, coordinator, errChan) + + // Start input handler if interactive mode is enabled + if interactive { + inputHandler := NewInputHandler(m, coordinator, cancel) + go inputHandler.Start(ctx, errChan) + } } + // Handle Ctrl+C signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + defer signal.Stop(sigChan) // Clean up signal handler when goroutine exits + for { + select { + case <-ctx.Done(): + return + case <-sigChan: + if interactive { + // Interactive mode (task chat) + // Check if input is currently being shown + if coordinator.IsInputAllowed() { + // Input form is showing - huh will handle the signal via ErrUserAborted + // Do nothing here, let the input handler deal with it + } else { + // Streaming mode - cancel the task and stay in follow mode + m.renderer.RenderTaskCancelled() + if err := m.CancelTask(context.Background()); err != nil { + fmt.Printf("Error cancelling task: %v\n", err) + } + // Don't cancel main context - stay in follow mode + } + } else { + // Non-interactive mode (task view --follow) + // Just exit without canceling the task + cancel() + return // Exit the loop after canceling in non-interactive mode + } + } + } + }() + // Wait for either stream to error or context cancellation select { case <-ctx.Done(): + // Check if this was a user-initiated cancellation (Ctrl+C) + // Return nil for clean exit instead of context.Canceled error + if ctx.Err() == context.Canceled { + return nil + } return ctx.Err() case err := <-errChan: cancel() @@ -585,7 +755,12 @@ func (m *Manager) FollowConversation(ctx context.Context) error { // FollowConversationUntilCompletion streams conversation updates until task completion func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error { - fmt.Println("Streaming conversation until completion... (Press Ctrl+C to exit)") + // Enable streaming mode + m.mu.Lock() + m.isStreamingMode = true + m.mu.Unlock() + + fmt.Println("Following task conversation until completion... (Press Ctrl+C to exit)") ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -593,10 +768,10 @@ func (m *Manager) FollowConversationUntilCompletion(ctx context.Context) error { // Create stream coordinator coordinator := NewStreamCoordinator() - // Get current message count without displaying history - totalMessageCount, err := m.getCurrentMessageCount(ctx) + // Load history first + totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx) if err != nil { - m.renderer.RenderDebug("Warning: Failed to get current message count: %v", err) + m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err) totalMessageCount = 0 } coordinator.SetConversationTurnStartIndex(totalMessageCount) @@ -726,6 +901,9 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat // processStateUpdate processes state updates and supports logic for handling task competion markers func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error { + // Update current mode from state + m.updateMode(stateUpdate.StateJson) + messages, err := m.extractMessagesFromState(stateUpdate.StateJson) if err != nil { return err @@ -749,27 +927,96 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre foundCompletion = true } - // Currently handling a subset of message types for displaying switch { case msg.Say == string(types.SayTypeUserFeedback): - if !coordinator.IsProcessedInCurrentTurn("user_msg") { + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() m.displayMessage(msg, false, false, i) - coordinator.MarkProcessedInCurrentTurn("user_msg") + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Say == string(types.SayTypeCommand): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Say == string(types.SayTypeCommandOutput): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Say == string(types.SayTypeBrowserActionLaunch): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Say == string(types.SayTypeMcpServerRequestStarted): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) } case msg.Say == string(types.SayTypeCheckpointCreated): - if !coordinator.IsProcessedInCurrentTurn("checkpoint") { + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() m.displayMessage(msg, false, false, i) - coordinator.MarkProcessedInCurrentTurn("checkpoint") + + coordinator.MarkProcessedInCurrentTurn(msgKey) } case msg.Say == string(types.SayTypeAPIReqStarted): + msgKey := fmt.Sprintf("%d", msg.Timestamp) apiInfo := types.APIRequestInfo{Cost: -1} if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 { - fmt.Println() // adds a separator between cline message and usage message + if !coordinator.IsProcessedInCurrentTurn(msgKey) { + fmt.Println() // adds a separator between cline message and usage message + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) + coordinator.CompleteTurn(len(messages)) + displayedUsage = true + } + } + + case msg.Ask == string(types.AskTypeCommandOutput): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + if !coordinator.IsProcessedInCurrentTurn(msgKey) { m.displayMessage(msg, false, false, i) - coordinator.CompleteTurn(len(messages)) - displayedUsage = true + + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Ask == string(types.AskTypePlanModeRespond): + msgKey := fmt.Sprintf("%d", msg.Timestamp) + // Non-streaming mode: render normally when message is complete + if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) { + m.displayMessage(msg, false, false, i) + + coordinator.MarkProcessedInCurrentTurn(msgKey) + } + + case msg.Type == types.MessageTypeAsk: + msgKey := fmt.Sprintf("%d", msg.Timestamp) + // Only render if not already handled by partial stream + if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) { + m.displayMessage(msg, false, false, i) + coordinator.MarkProcessedInCurrentTurn(msgKey) } } } @@ -790,6 +1037,10 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S return } + defer func() { + m.streamingDisplay.FreezeActiveSegment() + }() + for { select { case <-ctx.Done(): @@ -810,7 +1061,7 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S msg.Type, msg.Partial, len(msg.Text)) // Handle the message with streaming support for de-dupping - if err := m.handleStreamingMessage(msg); err != nil { + if err := m.handleStreamingMessage(msg, coordinator); err != nil { m.renderer.RenderDebug("Error handling streaming message: %v", err) } } @@ -818,7 +1069,7 @@ func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *S } // handleStreamingMessage handles a streaming message -func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error { +func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *StreamCoordinator) error { // Debug: Always log what we're processing m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s", msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50)) @@ -827,7 +1078,7 @@ func (m *Manager) handleStreamingMessage(msg *types.ClineMessage) error { if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil { m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err) // Fallback to regular display - return m.displayMessage(msg, true, false, -1) + m.displayMessage(msg, true, false, -1) } return nil @@ -846,12 +1097,21 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool if global.Config.OutputFormat == "json" { return m.outputMessageAsJSON(msg) } else { + m.mu.RLock() + isStreaming := m.isStreamingMode + isInteractive := m.isInteractive + m.mu.RUnlock() + dc := &handlers.DisplayContext{ - State: m.state, - Renderer: m.renderer, - IsLast: isLast, - IsPartial: isPartial, - MessageIndex: messageIndex, + State: m.state, + Renderer: m.renderer, + ToolRenderer: m.toolRenderer, + SystemRenderer: m.systemRenderer, + IsLast: isLast, + IsPartial: isPartial, + MessageIndex: messageIndex, + IsStreamingMode: isStreaming, + IsInteractive: isInteractive, } return m.handlerRegistry.Handle(msg, dc) @@ -869,21 +1129,6 @@ func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error { return nil } -// getCurrentMessageCount gets the current message count without displaying messages -func (m *Manager) getCurrentMessageCount(ctx context.Context) (int, error) { - state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{}) - if err != nil { - return 0, fmt.Errorf("failed to get state: %w", err) - } - - messages, err := m.extractMessagesFromState(state.StateJson) - if err != nil { - return 0, fmt.Errorf("failed to extract messages: %w", err) - } - - return len(messages), nil -} - // loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) { // Get the latest state which contains messages @@ -910,16 +1155,30 @@ func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) if totalMessages > maxHistoryMessages { startIndex = totalMessages - maxHistoryMessages - fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages) + if global.Config.OutputFormat != "plain" { + markdown := fmt.Sprintf("*Conversation history (%d of %d messages)*", maxHistoryMessages, totalMessages) + rendered := m.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n\n", rendered) + } else { + fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages) + } } else { - fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages) + if global.Config.OutputFormat != "plain" { + markdown := fmt.Sprintf("*Conversation history (%d messages)*", totalMessages) + rendered := m.renderer.RenderMarkdown(markdown) + fmt.Printf("\n%s\n\n", rendered) + } else { + fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages) + } } - // Display recent messages for i := startIndex; i < len(messages); i++ { msg := messages[i] - // Display the message + if msg.Partial { + continue + } + m.displayMessage(msg, false, false, i) } @@ -937,6 +1196,85 @@ func (m *Manager) GetState() *types.ConversationState { return m.state } +// GetClient returns the underlying ClineClient for direct gRPC calls +func (m *Manager) GetClient() *client.ClineClient { + m.mu.RLock() + defer m.mu.RUnlock() + return m.client +} + +// GetRenderer returns the renderer for formatting output +func (m *Manager) GetRenderer() *display.Renderer { + return m.renderer +} + +// GetCurrentMode returns the current plan/act mode +func (m *Manager) GetCurrentMode() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.currentMode +} + +// extractModeFromState extracts the current mode from state JSON +func (m *Manager) extractModeFromState(stateJson string) string { + var rawState map[string]interface{} + if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil { + return m.currentMode // Return current mode if parsing fails + } + + if mode, ok := rawState["mode"].(string); ok { + return mode + } + + return m.currentMode // Return current mode if not found in state +} + +// updateMode updates the current mode from state +func (m *Manager) updateMode(stateJson string) { + mode := m.extractModeFromState(stateJson) + m.mu.Lock() + m.currentMode = mode + m.mu.Unlock() +} + +// UpdateTaskAutoApprovalAction enables a specific auto-approval action for the current task +func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey string) error { + settings := &cline.Settings{ + AutoApprovalSettings: &cline.AutoApprovalSettings{ + Enabled: true, + MaxRequests: 20, // Important: avoid maxRequests=0 bug + Actions: &cline.AutoApprovalActions{}, + }, + } + + // Set the specific action to true based on actionKey + truePtr := func() *bool { b := true; return &b }() + + switch actionKey { + case "read_files": + settings.AutoApprovalSettings.Actions.ReadFiles = truePtr + case "edit_files": + settings.AutoApprovalSettings.Actions.EditFiles = truePtr + case "execute_all_commands": + settings.AutoApprovalSettings.Actions.ExecuteAllCommands = truePtr + case "use_browser": + settings.AutoApprovalSettings.Actions.UseBrowser = truePtr + case "use_mcp": + settings.AutoApprovalSettings.Actions.UseMcp = truePtr + default: + return fmt.Errorf("unknown auto-approval action: %s", actionKey) + } + + _, err := m.client.State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{ + Settings: settings, + }) + if err != nil { + return fmt.Errorf("failed to update task settings: %w", err) + } + + return nil +} + // Cleanup cleans up resources func (m *Manager) Cleanup() { // Clean up streaming display resources if needed diff --git a/cli/pkg/cli/task/settings_parser.go b/cli/pkg/cli/task/settings_parser.go new file mode 100644 index 00000000000..18117fa0551 --- /dev/null +++ b/cli/pkg/cli/task/settings_parser.go @@ -0,0 +1,768 @@ +package task + +import ( + "fmt" + "strconv" + "strings" + + "github.com/cline/grpc-go/cline" +) + + +func ParseTaskSettings(settingsFlags []string) (*cline.Settings, *cline.Secrets, error) { + if len(settingsFlags) == 0 { + return nil, nil, nil + } + + settings := &cline.Settings{} + secrets := &cline.Secrets{} + nestedSettings := make(map[string]map[string]string) + + for _, flag := range settingsFlags { + // Parse key=value + parts := strings.SplitN(flag, "=", 2) + if len(parts) != 2 { + return nil, nil, fmt.Errorf("invalid setting format '%s': expected key=value", flag) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Convert kebab-case to snake_case + key = kebabToSnake(key) + + // Check if this is a nested setting (contains a dot) + if strings.Contains(key, ".") { + dotParts := strings.SplitN(key, ".", 2) + parentField := dotParts[0] + childField := dotParts[1] + + if nestedSettings[parentField] == nil { + nestedSettings[parentField] = make(map[string]string) + } + nestedSettings[parentField][childField] = value + } else { + // Check if it's a secret field first, then settings field + if err := setSecretField(secrets, key, value); err == nil { + // Successfully set as secret, continue + continue + } + // Not a secret, try as a settings field + if err := setSimpleField(settings, key, value); err != nil { + return nil, nil, fmt.Errorf("error setting field '%s': %w", key, err) + } + } + } + + // Process nested settings + for parentField, childFields := range nestedSettings { + if err := setNestedField(settings, parentField, childFields); err != nil { + return nil, nil, fmt.Errorf("error setting nested field '%s': %w", parentField, err) + } + } + + return settings, secrets, nil +} + +// kebabToSnake converts kebab-case to snake_case +func kebabToSnake(s string) string { + return strings.ReplaceAll(s, "-", "_") +} + +// Pointer helper functions for optional protobuf fields +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } +func int32Ptr(i int32) *int32 { return &i } +func int64Ptr(i int64) *int64 { return &i } +func float64Ptr(f float64) *float64 { return &f } + +// setSimpleField sets a simple (non-nested) field on Settings +func setSimpleField(settings *cline.Settings, key, value string) error { + switch key { + // String fields + case "aws_region": + settings.AwsRegion = strPtr(value) + case "aws_bedrock_endpoint": + settings.AwsBedrockEndpoint = strPtr(value) + case "aws_profile": + settings.AwsProfile = strPtr(value) + case "aws_authentication": + settings.AwsAuthentication = strPtr(value) + case "vertex_project_id": + settings.VertexProjectId = strPtr(value) + case "vertex_region": + settings.VertexRegion = strPtr(value) + case "requesty_base_url": + settings.RequestyBaseUrl = strPtr(value) + case "open_ai_base_url": + settings.OpenAiBaseUrl = strPtr(value) + case "ollama_base_url": + settings.OllamaBaseUrl = strPtr(value) + case "ollama_api_options_ctx_num": + settings.OllamaApiOptionsCtxNum = strPtr(value) + case "lm_studio_base_url": + settings.LmStudioBaseUrl = strPtr(value) + case "lm_studio_max_tokens": + settings.LmStudioMaxTokens = strPtr(value) + case "anthropic_base_url": + settings.AnthropicBaseUrl = strPtr(value) + case "gemini_base_url": + settings.GeminiBaseUrl = strPtr(value) + case "azure_api_version": + settings.AzureApiVersion = strPtr(value) + case "open_router_provider_sorting": + settings.OpenRouterProviderSorting = strPtr(value) + case "lite_llm_base_url": + settings.LiteLlmBaseUrl = strPtr(value) + case "qwen_api_line": + settings.QwenApiLine = strPtr(value) + case "moonshot_api_line": + settings.MoonshotApiLine = strPtr(value) + case "zai_api_line": + settings.ZaiApiLine = strPtr(value) + case "telemetry_setting": + settings.TelemetrySetting = strPtr(value) + case "asksage_api_url": + settings.AsksageApiUrl = strPtr(value) + case "default_terminal_profile": + settings.DefaultTerminalProfile = strPtr(value) + case "sap_ai_core_token_url": + settings.SapAiCoreTokenUrl = strPtr(value) + case "sap_ai_core_base_url": + settings.SapAiCoreBaseUrl = strPtr(value) + case "sap_ai_resource_group": + settings.SapAiResourceGroup = strPtr(value) + case "claude_code_path": + settings.ClaudeCodePath = strPtr(value) + case "qwen_code_oauth_path": + settings.QwenCodeOauthPath = strPtr(value) + case "preferred_language": + settings.PreferredLanguage = strPtr(value) + case "custom_prompt": + settings.CustomPrompt = strPtr(value) + case "dify_base_url": + settings.DifyBaseUrl = strPtr(value) + case "oca_base_url": + settings.OcaBaseUrl = strPtr(value) + case "plan_mode_api_model_id": + settings.PlanModeApiModelId = strPtr(value) + case "plan_mode_reasoning_effort": + settings.PlanModeReasoningEffort = strPtr(value) + case "plan_mode_aws_bedrock_custom_model_base_id": + settings.PlanModeAwsBedrockCustomModelBaseId = strPtr(value) + case "plan_mode_open_router_model_id": + settings.PlanModeOpenRouterModelId = strPtr(value) + case "plan_mode_open_ai_model_id": + settings.PlanModeOpenAiModelId = strPtr(value) + case "plan_mode_ollama_model_id": + settings.PlanModeOllamaModelId = strPtr(value) + case "plan_mode_lm_studio_model_id": + settings.PlanModeLmStudioModelId = strPtr(value) + case "plan_mode_lite_llm_model_id": + settings.PlanModeLiteLlmModelId = strPtr(value) + case "plan_mode_requesty_model_id": + settings.PlanModeRequestyModelId = strPtr(value) + case "plan_mode_together_model_id": + settings.PlanModeTogetherModelId = strPtr(value) + case "plan_mode_fireworks_model_id": + settings.PlanModeFireworksModelId = strPtr(value) + case "plan_mode_sap_ai_core_model_id": + settings.PlanModeSapAiCoreModelId = strPtr(value) + case "plan_mode_sap_ai_core_deployment_id": + settings.PlanModeSapAiCoreDeploymentId = strPtr(value) + case "plan_mode_groq_model_id": + settings.PlanModeGroqModelId = strPtr(value) + case "plan_mode_baseten_model_id": + settings.PlanModeBasetenModelId = strPtr(value) + case "plan_mode_hugging_face_model_id": + settings.PlanModeHuggingFaceModelId = strPtr(value) + case "plan_mode_huawei_cloud_maas_model_id": + settings.PlanModeHuaweiCloudMaasModelId = strPtr(value) + case "plan_mode_oca_model_id": + settings.PlanModeOcaModelId = strPtr(value) + case "plan_mode_vercel_ai_gateway_model_id": + settings.PlanModeVercelAiGatewayModelId = strPtr(value) + case "act_mode_api_model_id": + settings.ActModeApiModelId = strPtr(value) + case "act_mode_reasoning_effort": + settings.ActModeReasoningEffort = strPtr(value) + case "act_mode_aws_bedrock_custom_model_base_id": + settings.ActModeAwsBedrockCustomModelBaseId = strPtr(value) + case "act_mode_open_router_model_id": + settings.ActModeOpenRouterModelId = strPtr(value) + case "act_mode_open_ai_model_id": + settings.ActModeOpenAiModelId = strPtr(value) + case "act_mode_ollama_model_id": + settings.ActModeOllamaModelId = strPtr(value) + case "act_mode_lm_studio_model_id": + settings.ActModeLmStudioModelId = strPtr(value) + case "act_mode_lite_llm_model_id": + settings.ActModeLiteLlmModelId = strPtr(value) + case "act_mode_requesty_model_id": + settings.ActModeRequestyModelId = strPtr(value) + case "act_mode_together_model_id": + settings.ActModeTogetherModelId = strPtr(value) + case "act_mode_fireworks_model_id": + settings.ActModeFireworksModelId = strPtr(value) + case "act_mode_sap_ai_core_model_id": + settings.ActModeSapAiCoreModelId = strPtr(value) + case "act_mode_sap_ai_core_deployment_id": + settings.ActModeSapAiCoreDeploymentId = strPtr(value) + case "act_mode_groq_model_id": + settings.ActModeGroqModelId = strPtr(value) + case "act_mode_baseten_model_id": + settings.ActModeBasetenModelId = strPtr(value) + case "act_mode_hugging_face_model_id": + settings.ActModeHuggingFaceModelId = strPtr(value) + case "act_mode_huawei_cloud_maas_model_id": + settings.ActModeHuaweiCloudMaasModelId = strPtr(value) + case "act_mode_oca_model_id": + settings.ActModeOcaModelId = strPtr(value) + case "act_mode_vercel_ai_gateway_model_id": + settings.ActModeVercelAiGatewayModelId = strPtr(value) + + // Boolean fields + case "aws_use_cross_region_inference": + val, err := parseBool(value) + if err != nil { + return err + } + settings.AwsUseCrossRegionInference = boolPtr(val) + case "aws_bedrock_use_prompt_cache": + val, err := parseBool(value) + if err != nil { + return err + } + settings.AwsBedrockUsePromptCache = boolPtr(val) + case "aws_use_profile": + val, err := parseBool(value) + if err != nil { + return err + } + settings.AwsUseProfile = boolPtr(val) + case "lite_llm_use_prompt_cache": + val, err := parseBool(value) + if err != nil { + return err + } + settings.LiteLlmUsePromptCache = boolPtr(val) + case "plan_act_separate_models_setting": + val, err := parseBool(value) + if err != nil { + return err + } + settings.PlanActSeparateModelsSetting = boolPtr(val) + case "enable_checkpoints_setting": + val, err := parseBool(value) + if err != nil { + return err + } + settings.EnableCheckpointsSetting = boolPtr(val) + case "sap_ai_core_use_orchestration_mode": + val, err := parseBool(value) + if err != nil { + return err + } + settings.SapAiCoreUseOrchestrationMode = boolPtr(val) + case "strict_plan_mode_enabled": + val, err := parseBool(value) + if err != nil { + return err + } + settings.StrictPlanModeEnabled = boolPtr(val) + case "yolo_mode_toggled": + val, err := parseBool(value) + if err != nil { + return err + } + settings.YoloModeToggled = boolPtr(val) + case "use_auto_condense": + val, err := parseBool(value) + if err != nil { + return err + } + settings.UseAutoCondense = boolPtr(val) + case "plan_mode_aws_bedrock_custom_selected": + val, err := parseBool(value) + if err != nil { + return err + } + settings.PlanModeAwsBedrockCustomSelected = boolPtr(val) + case "act_mode_aws_bedrock_custom_selected": + val, err := parseBool(value) + if err != nil { + return err + } + settings.ActModeAwsBedrockCustomSelected = boolPtr(val) + + // Integer fields + case "request_timeout_ms": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.RequestTimeoutMs = int32Ptr(val) + case "shell_integration_timeout": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.ShellIntegrationTimeout = int32Ptr(val) + case "terminal_output_line_limit": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.TerminalOutputLineLimit = int32Ptr(val) + case "max_consecutive_mistakes": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.MaxConsecutiveMistakes = int32Ptr(val) + case "fireworks_model_max_completion_tokens": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.FireworksModelMaxCompletionTokens = int32Ptr(val) + case "fireworks_model_max_tokens": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.FireworksModelMaxTokens = int32Ptr(val) + + // Int64 fields + case "plan_mode_thinking_budget_tokens": + val, err := parseInt64(value) + if err != nil { + return err + } + settings.PlanModeThinkingBudgetTokens = int64Ptr(val) + case "act_mode_thinking_budget_tokens": + val, err := parseInt64(value) + if err != nil { + return err + } + settings.ActModeThinkingBudgetTokens = int64Ptr(val) + + // Double fields + case "auto_condense_threshold": + val, err := parseFloat64(value) + if err != nil { + return err + } + settings.AutoCondenseThreshold = float64Ptr(val) + + // Enum fields + // Note: We can use &val directly for enums because the parser functions return a new local variable. + // This is different from using &value (the loop variable), which would cause all fields to share + // the same memory address. + case "openai_reasoning_effort": + val, err := parseOpenaiReasoningEffort(value) + if err != nil { + return err + } + settings.OpenaiReasoningEffort = &val + case "mode": + val, err := parsePlanActMode(value) + if err != nil { + return err + } + settings.Mode = &val + case "plan_mode_api_provider": + val, err := parseApiProvider(value) + if err != nil { + return err + } + settings.PlanModeApiProvider = &val + case "act_mode_api_provider": + val, err := parseApiProvider(value) + if err != nil { + return err + } + settings.ActModeApiProvider = &val + + default: + return fmt.Errorf("unsupported field '%s'", key) + } + + return nil +} + +// setNestedField sets a nested field on Settings +// Currently supports: auto_approval_settings, browser_settings +func setNestedField(settings *cline.Settings, parentField string, childFields map[string]string) error { + switch parentField { + case "auto_approval_settings": + if settings.AutoApprovalSettings == nil { + settings.AutoApprovalSettings = &cline.AutoApprovalSettings{} + } + return setAutoApprovalSettings(settings.AutoApprovalSettings, childFields) + + case "browser_settings": + if settings.BrowserSettings == nil { + settings.BrowserSettings = &cline.BrowserSettings{} + } + return setBrowserSettings(settings.BrowserSettings, childFields) + + default: + return fmt.Errorf("unsupported nested field '%s' (complex nested types are not supported via -s flags)", parentField) + } +} + +// setAutoApprovalSettings sets fields on AutoApprovalSettings +func setAutoApprovalSettings(settings *cline.AutoApprovalSettings, fields map[string]string) error { + for key, value := range fields { + switch key { + case "enabled": + val, err := parseBool(value) + if err != nil { + return err + } + settings.Enabled = val + case "max_requests": + val, err := parseInt32(value) + if err != nil { + return err + } + settings.MaxRequests = val + case "enable_notifications": + val, err := parseBool(value) + if err != nil { + return err + } + settings.EnableNotifications = val + case "actions": + return fmt.Errorf("auto_approval_settings.actions requires nested dot notation (e.g., auto-approval-settings.actions.read-files=true)") + default: + // Check if this is an action field (actions.*) + if strings.HasPrefix(key, "actions.") { + actionField := strings.TrimPrefix(key, "actions.") + if settings.Actions == nil { + settings.Actions = &cline.AutoApprovalActions{} + } + if err := setAutoApprovalAction(settings.Actions, actionField, value); err != nil { + return err + } + // Continue processing other fields + } else { + return fmt.Errorf("unsupported auto_approval_settings field '%s'", key) + } + } + } + return nil +} + +// setAutoApprovalAction sets fields on AutoApprovalActions +func setAutoApprovalAction(actions *cline.AutoApprovalActions, key, value string) error { + val, err := parseBool(value) + if err != nil { + return err + } + + switch key { + case "read_files": + actions.ReadFiles = boolPtr(val) + case "read_files_externally": + actions.ReadFilesExternally = boolPtr(val) + case "edit_files": + actions.EditFiles = boolPtr(val) + case "edit_files_externally": + actions.EditFilesExternally = boolPtr(val) + case "execute_safe_commands": + actions.ExecuteSafeCommands = boolPtr(val) + case "execute_all_commands": + actions.ExecuteAllCommands = boolPtr(val) + case "use_browser": + actions.UseBrowser = boolPtr(val) + case "use_mcp": + actions.UseMcp = boolPtr(val) + default: + return fmt.Errorf("unsupported auto_approval_actions field '%s'", key) + } + + return nil +} + +// setBrowserSettings sets fields on BrowserSettings +func setBrowserSettings(settings *cline.BrowserSettings, fields map[string]string) error { + for key, value := range fields { + switch key { + case "viewport_width": + val, err := parseInt32(value) + if err != nil { + return err + } + if settings.Viewport == nil { + settings.Viewport = &cline.Viewport{} + } + settings.Viewport.Width = val + case "viewport_height": + val, err := parseInt32(value) + if err != nil { + return err + } + if settings.Viewport == nil { + settings.Viewport = &cline.Viewport{} + } + settings.Viewport.Height = val + case "remote_browser_host": + settings.RemoteBrowserHost = strPtr(value) + case "remote_browser_enabled": + val, err := parseBool(value) + if err != nil { + return err + } + settings.RemoteBrowserEnabled = boolPtr(val) + case "chrome_executable_path": + settings.ChromeExecutablePath = strPtr(value) + case "disable_tool_use": + val, err := parseBool(value) + if err != nil { + return err + } + settings.DisableToolUse = boolPtr(val) + case "custom_args": + settings.CustomArgs = strPtr(value) + default: + return fmt.Errorf("unsupported browser_settings field '%s'", key) + } + } + return nil +} + +// Type parsing helpers +func parseBool(value string) (bool, error) { + lower := strings.ToLower(value) + switch lower { + case "true", "t", "yes", "y", "1": + return true, nil + case "false", "f", "no", "n", "0": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value '%s': expected true/false", value) + } +} + +func parseInt32(value string) (int32, error) { + val, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid integer value '%s': %w", value, err) + } + return int32(val), nil +} + +func parseInt64(value string) (int64, error) { + val, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer value '%s': %w", value, err) + } + return val, nil +} + +func parseFloat64(value string) (float64, error) { + val, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("invalid float value '%s': %w", value, err) + } + return val, nil +} + +// Enum parsing helpers +func parseOpenaiReasoningEffort(value string) (cline.OpenaiReasoningEffort, error) { + lower := strings.ToLower(value) + switch lower { + case "low": + return cline.OpenaiReasoningEffort_LOW, nil + case "medium": + return cline.OpenaiReasoningEffort_MEDIUM, nil + case "high": + return cline.OpenaiReasoningEffort_HIGH, nil + default: + return cline.OpenaiReasoningEffort_LOW, fmt.Errorf("invalid openai_reasoning_effort '%s': expected low/medium/high", value) + } +} + +func parsePlanActMode(value string) (cline.PlanActMode, error) { + lower := strings.ToLower(value) + switch lower { + case "plan": + return cline.PlanActMode_PLAN, nil + case "act": + return cline.PlanActMode_ACT, nil + default: + return cline.PlanActMode_ACT, fmt.Errorf("invalid mode '%s': expected plan/act", value) + } +} + +func parseApiProvider(value string) (cline.ApiProvider, error) { + lower := strings.ToLower(value) + switch lower { + case "anthropic": + return cline.ApiProvider_ANTHROPIC, nil + case "openrouter": + return cline.ApiProvider_OPENROUTER, nil + case "bedrock": + return cline.ApiProvider_BEDROCK, nil + case "vertex": + return cline.ApiProvider_VERTEX, nil + case "openai": + return cline.ApiProvider_OPENAI, nil + case "ollama": + return cline.ApiProvider_OLLAMA, nil + case "lmstudio": + return cline.ApiProvider_LMSTUDIO, nil + case "gemini": + return cline.ApiProvider_GEMINI, nil + case "openai_native": + return cline.ApiProvider_OPENAI_NATIVE, nil + case "requesty": + return cline.ApiProvider_REQUESTY, nil + case "together": + return cline.ApiProvider_TOGETHER, nil + case "deepseek": + return cline.ApiProvider_DEEPSEEK, nil + case "qwen": + return cline.ApiProvider_QWEN, nil + case "doubao": + return cline.ApiProvider_DOUBAO, nil + case "mistral": + return cline.ApiProvider_MISTRAL, nil + case "vscode_lm": + return cline.ApiProvider_VSCODE_LM, nil + case "cline": + return cline.ApiProvider_CLINE, nil + case "litellm": + return cline.ApiProvider_LITELLM, nil + case "nebius": + return cline.ApiProvider_NEBIUS, nil + case "fireworks": + return cline.ApiProvider_FIREWORKS, nil + case "asksage": + return cline.ApiProvider_ASKSAGE, nil + case "xai", "grok": + return cline.ApiProvider_XAI, nil + case "sambanova": + return cline.ApiProvider_SAMBANOVA, nil + case "cerebras": + return cline.ApiProvider_CEREBRAS, nil + case "groq": + return cline.ApiProvider_GROQ, nil + case "sapaicore", "sap_ai_core": + return cline.ApiProvider_SAPAICORE, nil + case "claude_code": + return cline.ApiProvider_CLAUDE_CODE, nil + case "moonshot": + return cline.ApiProvider_MOONSHOT, nil + case "huggingface": + return cline.ApiProvider_HUGGINGFACE, nil + case "huawei_cloud_maas": + return cline.ApiProvider_HUAWEI_CLOUD_MAAS, nil + case "baseten": + return cline.ApiProvider_BASETEN, nil + case "zai": + return cline.ApiProvider_ZAI, nil + case "vercel_ai_gateway": + return cline.ApiProvider_VERCEL_AI_GATEWAY, nil + case "qwen_code": + return cline.ApiProvider_QWEN_CODE, nil + case "dify": + return cline.ApiProvider_DIFY, nil + case "oca": + return cline.ApiProvider_OCA, nil + default: + return cline.ApiProvider_ANTHROPIC, fmt.Errorf("invalid api_provider '%s'", value) + } +} + +// setSecretField sets a secret field on Secrets +// All secret fields are optional strings +// Returns nil if field was successfully set, error otherwise +func setSecretField(secrets *cline.Secrets, key, value string) error { + switch key { + case "api_key": + secrets.ApiKey = strPtr(value) + case "open_router_api_key": + secrets.OpenRouterApiKey = strPtr(value) + case "aws_access_key": + secrets.AwsAccessKey = strPtr(value) + case "aws_secret_key": + secrets.AwsSecretKey = strPtr(value) + case "aws_session_token": + secrets.AwsSessionToken = strPtr(value) + case "aws_bedrock_api_key": + secrets.AwsBedrockApiKey = strPtr(value) + case "open_ai_api_key": + secrets.OpenAiApiKey = strPtr(value) + case "gemini_api_key": + secrets.GeminiApiKey = strPtr(value) + case "open_ai_native_api_key": + secrets.OpenAiNativeApiKey = strPtr(value) + case "ollama_api_key": + secrets.OllamaApiKey = strPtr(value) + case "deep_seek_api_key": + secrets.DeepSeekApiKey = strPtr(value) + case "requesty_api_key": + secrets.RequestyApiKey = strPtr(value) + case "together_api_key": + secrets.TogetherApiKey = strPtr(value) + case "fireworks_api_key": + secrets.FireworksApiKey = strPtr(value) + case "qwen_api_key": + secrets.QwenApiKey = strPtr(value) + case "doubao_api_key": + secrets.DoubaoApiKey = strPtr(value) + case "mistral_api_key": + secrets.MistralApiKey = strPtr(value) + case "lite_llm_api_key": + secrets.LiteLlmApiKey = strPtr(value) + case "auth_nonce": + secrets.AuthNonce = strPtr(value) + case "asksage_api_key": + secrets.AsksageApiKey = strPtr(value) + case "xai_api_key": + secrets.XaiApiKey = strPtr(value) + case "moonshot_api_key": + secrets.MoonshotApiKey = strPtr(value) + case "zai_api_key": + secrets.ZaiApiKey = strPtr(value) + case "hugging_face_api_key": + secrets.HuggingFaceApiKey = strPtr(value) + case "nebius_api_key": + secrets.NebiusApiKey = strPtr(value) + case "sambanova_api_key": + secrets.SambanovaApiKey = strPtr(value) + case "cerebras_api_key": + secrets.CerebrasApiKey = strPtr(value) + case "sap_ai_core_client_id": + secrets.SapAiCoreClientId = strPtr(value) + case "sap_ai_core_client_secret": + secrets.SapAiCoreClientSecret = strPtr(value) + case "groq_api_key": + secrets.GroqApiKey = strPtr(value) + case "huawei_cloud_maas_api_key": + secrets.HuaweiCloudMaasApiKey = strPtr(value) + case "baseten_api_key": + secrets.BasetenApiKey = strPtr(value) + case "vercel_ai_gateway_api_key": + secrets.VercelAiGatewayApiKey = strPtr(value) + case "dify_api_key": + secrets.DifyApiKey = strPtr(value) + case "oca_api_key": + secrets.OcaApiKey = strPtr(value) + case "oca_refresh_token": + secrets.OcaRefreshToken = strPtr(value) + default: + return fmt.Errorf("unsupported secret field '%s'", key) + } + + return nil +} + +// Note: message types not supported via -s flags: +// - OpenRouterModelInfo, OpenAiCompatibleModelInfo, LiteLLMModelInfo, OcaModelInfo +// - LanguageModelChatSelector +// - DictationSettings +// - FocusChainSettings diff --git a/cli/pkg/cli/task/stream_coordinator.go b/cli/pkg/cli/task/stream_coordinator.go index d029930831f..dec062466dc 100644 --- a/cli/pkg/cli/task/stream_coordinator.go +++ b/cli/pkg/cli/task/stream_coordinator.go @@ -1,9 +1,13 @@ package task +import "sync" + // StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams type StreamCoordinator struct { conversationTurnStartIndex int // First message index of current turn processedInCurrentTurn map[string]bool // What we've handled in THIS turn + inputAllowed bool // Whether user input is currently allowed + mu sync.RWMutex // Protects inputAllowed } // NewStreamCoordinator creates a new stream coordinator @@ -34,8 +38,23 @@ func (sc *StreamCoordinator) IsProcessedInCurrentTurn(key string) bool { return sc.processedInCurrentTurn[key] } -// CompleteTurn resets the coordinator for the next conversation turn +// CompleteTurn updates the start index for the next batch of messages +// Note: Does NOT reset the processed map - that persists across state updates func (sc *StreamCoordinator) CompleteTurn(totalMessages int) { sc.conversationTurnStartIndex = totalMessages - sc.processedInCurrentTurn = make(map[string]bool) + // Don't reset processedInCurrentTurn - it should persist across state updates +} + +// SetInputAllowed sets whether user input is currently allowed +func (sc *StreamCoordinator) SetInputAllowed(allowed bool) { + sc.mu.Lock() + defer sc.mu.Unlock() + sc.inputAllowed = allowed +} + +// IsInputAllowed returns whether user input is currently allowed +func (sc *StreamCoordinator) IsInputAllowed() bool { + sc.mu.RLock() + defer sc.mu.RUnlock() + return sc.inputAllowed } diff --git a/cli/pkg/cli/terminal/keyboard.go b/cli/pkg/cli/terminal/keyboard.go new file mode 100644 index 00000000000..735b09a360d --- /dev/null +++ b/cli/pkg/cli/terminal/keyboard.go @@ -0,0 +1,695 @@ +package terminal + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/cline/cli/pkg/cli/display" + "github.com/cline/cli/pkg/cli/global" +) + +// KeyboardProtocol manages enhanced keyboard protocol support for detecting +// modified keys like shift+enter across all major terminals. +type KeyboardProtocol struct { + enabled bool + mu sync.Mutex +} + +var globalProtocol = &KeyboardProtocol{} + +// EnableEnhancedKeyboard enables enhanced keyboard protocols to support +// shift+enter and other modified keys across all major terminals: +// - VS Code integrated terminal +// - iTerm2 +// - Terminal.app +// - Ghostty +// - Kitty +// - WezTerm +// - Alacritty +// - foot +// - xterm +// +// This function is safe to call multiple times and handles cleanup automatically. +// It enables both modifyOtherKeys (xterm protocol) and Kitty keyboard protocol +// for maximum compatibility. +func EnableEnhancedKeyboard() { + globalProtocol.mu.Lock() + defer globalProtocol.mu.Unlock() + + if globalProtocol.enabled { + return // Already enabled + } + + // Check if we're in a TTY (not piped/redirected) + if !isatty(os.Stdin.Fd()) { + return + } + + // Enable modifyOtherKeys mode 2 + // This tells xterm-compatible terminals (VS Code, iTerm2, Terminal.app, etc.) + // to send escape sequences for modified keys including shift+enter + // Format: CSI > 4 ; 2 m + // - Mode 2 enables for ALL keys including well-known ones + fmt.Print("\x1b[>4;2m") + + // Also enable Kitty keyboard protocol for terminals that support it + // This is a more modern protocol supported by Kitty, Ghostty, WezTerm, foot, etc. + // Format: CSI = u where flags=1 means "disambiguate escape codes" + // This makes shift+enter distinguishable from plain enter + fmt.Print("\x1b[=1u") + + globalProtocol.enabled = true +} + +// DisableEnhancedKeyboard restores the terminal to its default keyboard mode. +// This should be called on program exit to be a good citizen. +func DisableEnhancedKeyboard() { + globalProtocol.mu.Lock() + defer globalProtocol.mu.Unlock() + + if !globalProtocol.enabled { + return + } + + // Disable modifyOtherKeys (restore to mode 0) + fmt.Print("\x1b[>4;0m") + + // Disable Kitty keyboard protocol + fmt.Print("\x1b[ 0 + } + + // Base versions are equal, compare suffixes (nightly.19) + return compareSuffix(latestSuffix, currentSuffix) > 0 +} + +func parseVersion(version string) (string, string) { + parts := strings.SplitN(version, "-", 2) + if len(parts) == 2 { + return parts[0], parts[1] + } + return parts[0], "" +} + +func compareVersionParts(v1, v2 string) int { + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + for i := 0; i < len(parts1) && i < len(parts2); i++ { + // Convert to int for proper numeric comparison + n1 := parseInt(parts1[i]) + n2 := parseInt(parts2[i]) + + if n1 > n2 { + return 1 + } + if n1 < n2 { + return -1 + } + } + + // If all parts are equal, longer version is newer + if len(parts1) > len(parts2) { + return 1 + } + if len(parts1) < len(parts2) { + return -1 + } + return 0 +} + +func compareSuffix(s1, s2 string) int { + // If one has no suffix, stable > prerelease + if s1 == "" && s2 == "" { + return 0 + } + if s1 == "" { + return 1 // Stable is newer than prerelease + } + if s2 == "" { + return -1 // Prerelease is older than stable + } + + // Both have suffixes (e.g., "nightly.19" vs "nightly.18") + // Extract the numeric part after the last dot + n1 := extractBuildNumber(s1) + n2 := extractBuildNumber(s2) + + if n1 > n2 { + return 1 + } + if n1 < n2 { + return -1 + } + return 0 +} + +func extractBuildNumber(suffix string) int { + // Extract number from "nightly.19" -> 19 + parts := strings.Split(suffix, ".") + if len(parts) > 1 { + return parseInt(parts[len(parts)-1]) + } + return 0 +} + +func parseInt(s string) int { + var result int + fmt.Sscanf(s, "%d", &result) + return result +} + +func showSuccessMessage(version string) { + output.Printf("\n%s Updated to %s %s Changes will take effect next session\n\n", + successStyle.Render("✓"), + successStyle.Render("v"+version), + dimStyle.Render("→"), + ) +} + +func showFailureMessage(channel string) { + packageName := "cline" + if channel == "nightly" { + packageName = "cline@nightly" + } + + output.Printf("\n%s Auto-update failed %s Try: %s\n\n", + errorStyle.Render("✗"), + dimStyle.Render("·"), + "npm install -g "+packageName, + ) +} + +func getCacheFilePath() string { + configDir := filepath.Join(os.Getenv("HOME"), ".cline", "data") + return filepath.Join(configDir, ".update-cache") +} + +func loadCache() (cacheData, error) { + var cache cacheData + cacheFile := getCacheFilePath() + + data, err := os.ReadFile(cacheFile) + if err != nil { + return cache, err + } + + err = json.Unmarshal(data, &cache) + return cache, err +} + +func saveCache(cache cacheData) error { + cacheFile := getCacheFilePath() + + // Ensure config directory exists + configDir := filepath.Dir(cacheFile) + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + data, err := json.Marshal(cache) + if err != nil { + return err + } + + return os.WriteFile(cacheFile, data, 0644) +} diff --git a/cli/pkg/cli/version.go b/cli/pkg/cli/version.go index 972d92506e8..eb9aa5f31f5 100644 --- a/cli/pkg/cli/version.go +++ b/cli/pkg/cli/version.go @@ -4,38 +4,34 @@ import ( "fmt" "runtime" + "github.com/cline/cli/pkg/cli/global" "github.com/spf13/cobra" ) -var ( - // These will be set at build time via ldflags - Version = "dev" - Commit = "unknown" - Date = "unknown" - BuiltBy = "unknown" -) - // NewVersionCommand creates the version command func NewVersionCommand() *cobra.Command { var short bool cmd := &cobra.Command{ - Use: "version", - Short: "Show version information", - Long: `Display version information for the Cline Go host.`, + Use: "version", + Aliases: []string{"v"}, + Short: "Show version information", + Long: `Display version information for the Cline CLI.`, RunE: func(cmd *cobra.Command, args []string) error { + // Versions are injected at build time via ldflags if short { - fmt.Println(Version) + fmt.Println(global.CliVersion) return nil } - fmt.Printf("Cline Go Host\n") - fmt.Printf("Version: %s\n", Version) - fmt.Printf("Commit: %s\n", Commit) - fmt.Printf("Built: %s\n", Date) - fmt.Printf("Built by: %s\n", BuiltBy) - fmt.Printf("Go version: %s\n", runtime.Version()) - fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Printf("Cline CLI\n") + fmt.Printf("Cline CLI Version: %s\n", global.CliVersion) + fmt.Printf("Cline Core Version: %s\n", global.Version) + fmt.Printf("Commit: %s\n", global.Commit) + fmt.Printf("Built: %s\n", global.Date) + fmt.Printf("Built by: %s\n", global.BuiltBy) + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) return nil }, @@ -44,4 +40,4 @@ func NewVersionCommand() *cobra.Command { cmd.Flags().BoolVar(&short, "short", false, "show only version number") return cmd -} +} \ No newline at end of file diff --git a/cli/pkg/generated/field_overrides.go b/cli/pkg/generated/field_overrides.go new file mode 100644 index 00000000000..9031b2603de --- /dev/null +++ b/cli/pkg/generated/field_overrides.go @@ -0,0 +1,39 @@ +package generated + +// FieldOverrides allows manual control over field relevance per provider +// This file is NOT auto-generated and can be edited manually to override +// the automatic field filtering logic. +// +// Usage: +// - Add provider-specific overrides to force include/exclude fields +// - true = force include this field for this provider +// - false = force exclude this field for this provider +// - If no override exists, automatic filtering logic applies +var FieldOverrides = map[string]map[string]bool{ + // Format: "provider_id": {"field_name": shouldInclude} + + // Example overrides (uncomment and modify as needed): + + // "anthropic": { + // "requestTimeoutMs": true, // explicitly include + // "ollamaBaseUrl": false, // explicitly exclude + // }, + + // "bedrock": { + // "awsSessionToken": true, // include even if marked optional + // "azureApiVersion": false, // exclude even if general + // }, + + // Add more provider-specific overrides as needed +} + +// GetFieldOverride returns the override setting for a field, if one exists +// Returns (shouldInclude, hasOverride) +func GetFieldOverride(providerID, fieldName string) (bool, bool) { + if providerOverrides, exists := FieldOverrides[providerID]; exists { + if override, hasOverride := providerOverrides[fieldName]; hasOverride { + return override, true + } + } + return false, false +} diff --git a/cli/pkg/generated/providers.go b/cli/pkg/generated/providers.go new file mode 100644 index 00000000000..87827ddc970 --- /dev/null +++ b/cli/pkg/generated/providers.go @@ -0,0 +1,1504 @@ +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by scripts/generate-provider-definitions.mjs +// Source: src/shared/api.ts +// +// ============================================================================ +// DATA CONTRACT & DOCUMENTATION +// ============================================================================ +// +// This file provides structured provider metadata extracted from TypeScript source. +// It serves as the bridge between the VSCode extension's TypeScript API definitions +// and the CLI's Go-based setup wizard. +// +// CORE STRUCTURES +// =============== +// +// ConfigField: Individual configuration fields with type, category, and validation metadata +// - Name: Field name as it appears in ApiHandlerOptions (e.g., "cerebrasApiKey") +// - Type: TypeScript type (e.g., "string", "number") +// - Comment: Inline comment from TypeScript source +// - Category: Provider categorization (e.g., "cerebras", "general") +// - Required: Whether this field MUST be collected for any provider +// - FieldType: UI field type hint ("password", "url", "string", "select") +// - Placeholder: Suggested placeholder text for UI input +// +// ModelInfo: Model capabilities, pricing, and limits +// - MaxTokens: Maximum output tokens +// - ContextWindow: Total context window size +// - SupportsImages: Whether model accepts image inputs +// - SupportsPromptCache: Whether model supports prompt caching +// - InputPrice: Cost per 1M input tokens (USD) +// - OutputPrice: Cost per 1M output tokens (USD) +// - CacheWritesPrice: Cost per 1M cached tokens written (USD) +// - CacheReadsPrice: Cost per 1M cached tokens read (USD) +// - Description: Human-readable model description +// +// ProviderDefinition: Complete provider metadata including required/optional fields +// - ID: Provider identifier (e.g., "cerebras", "anthropic") +// - Name: Human-readable display name (e.g., "Cerebras", "Anthropic (Claude)") +// - RequiredFields: Fields that MUST be collected (filtered by category + overrides) +// - OptionalFields: Fields that MAY be collected (filtered by category + overrides) +// - Models: Map of model IDs to ModelInfo +// - DefaultModelID: Recommended default model from TypeScript source +// - HasDynamicModels: Whether provider supports runtime model discovery +// - SetupInstructions: User-facing setup guidance +// +// FIELD FILTERING LOGIC +// ===================== +// +// Fields are categorized during parsing based on provider-specific prefixes in field names: +// - "cerebrasApiKey" → category="cerebras" +// - "awsAccessKey" → category="aws" (used by bedrock) +// - "requestTimeoutMs" → category="general" (applies to all providers) +// +// The getFieldsByProvider() function filters fields using this priority: +// 1. Check field_overrides.go via GetFieldOverride() for manual corrections +// 2. Match field.Category against provider ID (primary filtering) +// 3. Apply hardcoded switch cases for complex provider relationships +// 4. Include universal fields (requestTimeoutMs, ulid, clineAccountId) for all providers +// +// Required vs Optional: +// - Fields are marked as required if they appear in the providerRequiredFields map +// in the generator script (scripts/generate-provider-definitions.mjs) +// - getFieldsByProvider() respects the required parameter to separate required/optional +// +// MODEL SELECTION +// =============== +// +// DefaultModelID extraction priority: +// 1. Exact match from TypeScript constant (e.g., cerebrasDefaultModelId = "llama-3.3-70b") +// 2. Pattern matching on model IDs ("latest", "default", "sonnet", "gpt-4", etc.) +// 3. First model in the models map +// +// Models map contains full capability and pricing data extracted from TypeScript model +// definitions (e.g., cerebrasModels, anthropicModels). +// +// HasDynamicModels indicates providers that support runtime model discovery via API +// (e.g., OpenRouter, Ollama, LM Studio). For these providers, the models map may be +// incomplete or a representative sample. +// +// USAGE EXAMPLE +// ============= +// +// def, err := GetProviderDefinition("cerebras") +// if err != nil { +// return err +// } +// +// // Collect required fields from user +// for _, field := range def.RequiredFields { +// value := promptUser(field.Name, field.Placeholder, field.FieldType == "password") +// config[field.Name] = value +// } +// +// // Use default model or let user choose +// if def.DefaultModelID != "" { +// config["modelId"] = def.DefaultModelID +// } +// +// EXTENDING & OVERRIDING +// ====================== +// +// DO NOT modify this generated file directly. Changes will be lost on regeneration. +// +// To fix incorrect field categorization: +// - Edit cli/pkg/generated/field_overrides.go +// - Add entries to GetFieldOverride() function +// - Example: Force "awsSessionToken" to be relevant for "bedrock" +// +// To change required fields: +// - Edit providerRequiredFields map in scripts/generate-provider-definitions.mjs +// - Rerun: npm run generate-provider-definitions +// +// To add new providers: +// - Add to ApiProvider type in src/shared/api.ts +// - Add fields to ApiHandlerOptions with provider-specific prefixes +// - Optionally add model definitions (e.g., export const newProviderModels = {...}) +// - Rerun generator +// +// To fix default model extraction: +// - Ensure TypeScript source has: export const DefaultModelId = "model-id" +// - Or update extractDefaultModelIds() patterns in generator script +// +// For upstream changes: +// - Submit pull request to src/shared/api.ts in the main repository +// +// ============================================================================ + +package generated + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Provider constants +const ( + ANTHROPIC = "anthropic" + OPENROUTER = "openrouter" + BEDROCK = "bedrock" + OPENAI = "openai" + OLLAMA = "ollama" + GEMINI = "gemini" + OPENAI_NATIVE = "openai-native" + XAI = "xai" + CEREBRAS = "cerebras" + OCA = "oca" +) + +// AllProviders returns a slice of enabled provider IDs for the CLI build. +// This is a filtered subset of all providers available in the VSCode extension. +// To modify which providers are included, edit ENABLED_PROVIDERS in scripts/cli-providers.mjs +var AllProviders = []string{ + "anthropic", + "openrouter", + "bedrock", + "openai", + "ollama", + "gemini", + "openai-native", + "xai", + "cerebras", + "oca", +} + +// ConfigField represents a configuration field requirement +type ConfigField struct { + Name string `json:"name"` + Type string `json:"type"` + Comment string `json:"comment"` + Category string `json:"category"` + Required bool `json:"required"` + FieldType string `json:"fieldType"` + Placeholder string `json:"placeholder"` +} + +// ModelInfo represents model capabilities and pricing +type ModelInfo struct { + MaxTokens int `json:"maxTokens,omitempty"` + ContextWindow int `json:"contextWindow,omitempty"` + SupportsImages bool `json:"supportsImages"` + SupportsPromptCache bool `json:"supportsPromptCache"` + InputPrice float64 `json:"inputPrice,omitempty"` + OutputPrice float64 `json:"outputPrice,omitempty"` + CacheWritesPrice float64 `json:"cacheWritesPrice,omitempty"` + CacheReadsPrice float64 `json:"cacheReadsPrice,omitempty"` + Description string `json:"description,omitempty"` +} + +// ProviderDefinition represents a provider's metadata and requirements +type ProviderDefinition struct { + ID string `json:"id"` + Name string `json:"name"` + RequiredFields []ConfigField `json:"requiredFields"` + OptionalFields []ConfigField `json:"optionalFields"` + Models map[string]ModelInfo `json:"models"` + DefaultModelID string `json:"defaultModelId"` + HasDynamicModels bool `json:"hasDynamicModels"` + SetupInstructions string `json:"setupInstructions"` +} + +// Raw configuration fields data (parsed from TypeScript) +var rawConfigFields = ` [ + { + "name": "apiKey", + "type": "string", + "comment": "anthropic", + "category": "anthropic", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "awsAccessKey", + "type": "string", + "comment": "", + "category": "bedrock", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "awsSecretKey", + "type": "string", + "comment": "", + "category": "bedrock", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "openRouterApiKey", + "type": "string", + "comment": "", + "category": "openrouter", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "awsSessionToken", + "type": "string", + "comment": "", + "category": "bedrock", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "awsBedrockApiKey", + "type": "string", + "comment": "", + "category": "bedrock", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "openAiApiKey", + "type": "string", + "comment": "", + "category": "openai", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "geminiApiKey", + "type": "string", + "comment": "", + "category": "gemini", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "openAiNativeApiKey", + "type": "string", + "comment": "", + "category": "openai-native", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "ollamaApiKey", + "type": "string", + "comment": "", + "category": "ollama", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "authNonce", + "type": "string", + "comment": "", + "category": "general", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "xaiApiKey", + "type": "string", + "comment": "", + "category": "xai", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "cerebrasApiKey", + "type": "string", + "comment": "", + "category": "cerebras", + "required": true, + "fieldType": "password", + "placeholder": "Enter your API key" + }, + { + "name": "ulid", + "type": "string", + "comment": "Used to identify the task in API requests", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "openAiHeaders", + "type": "Record", + "comment": "Custom headers for OpenAI requests", + "category": "openai", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "anthropicBaseUrl", + "type": "string", + "comment": "", + "category": "anthropic", + "required": false, + "fieldType": "url", + "placeholder": "https://api.example.com" + }, + { + "name": "openRouterProviderSorting", + "type": "string", + "comment": "", + "category": "openrouter", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "openAiBaseUrl", + "type": "string", + "comment": "", + "category": "openai", + "required": false, + "fieldType": "url", + "placeholder": "https://api.example.com" + }, + { + "name": "ollamaBaseUrl", + "type": "string", + "comment": "", + "category": "ollama", + "required": false, + "fieldType": "url", + "placeholder": "https://api.example.com" + }, + { + "name": "ollamaApiOptionsCtxNum", + "type": "string", + "comment": "", + "category": "ollama", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "geminiBaseUrl", + "type": "string", + "comment": "", + "category": "gemini", + "required": false, + "fieldType": "url", + "placeholder": "https://api.example.com" + }, + { + "name": "azureApiVersion", + "type": "string", + "comment": "", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "requestTimeoutMs", + "type": "number", + "comment": "", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "sapAiResourceGroup", + "type": "string", + "comment": "", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "onRetryAttempt", + "type": "(attempt: number, maxRetries: number, delay: number, error: any) => void", + "comment": "", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + }, + { + "name": "ocaBaseUrl", + "type": "string", + "comment": "", + "category": "general", + "required": false, + "fieldType": "url", + "placeholder": "https://api.example.com" + }, + { + "name": "ocaMode", + "type": "string", + "comment": "", + "category": "general", + "required": false, + "fieldType": "string", + "placeholder": "" + } + ]` + +// Raw model definitions data (parsed from TypeScript) +var rawModelDefinitions = ` { + "anthropic": { + "claude-sonnet-4-5-20250929": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-sonnet-4-5-20250929:1m": { + "maxTokens": 8192, + "contextWindow": 1000000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-haiku-4-5-20251001": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 1, + "outputPrice": 5, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-sonnet-4-20250514": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-sonnet-4-20250514:1m": { + "maxTokens": 8192, + "contextWindow": 1000000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-opus-4-1-20250805": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "cacheWritesPrice": 18, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-opus-4-20250514": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "cacheWritesPrice": 18, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-3-7-sonnet-20250219": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-3-5-sonnet-20241022": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-3-5-haiku-20241022": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 0, + "outputPrice": 4, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": false, + "supportsPromptCache": true + }, + "claude-3-opus-20240229": { + "maxTokens": 4096, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "cacheWritesPrice": 18, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "claude-3-haiku-20240307": { + "maxTokens": 4096, + "contextWindow": 200000, + "inputPrice": 0, + "outputPrice": 1, + "cacheWritesPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + } + }, + "bedrock": { + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0:1m": { + "maxTokens": 8192, + "contextWindow": 1000000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-haiku-4-5-20251001-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 1, + "outputPrice": 5, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-sonnet-4-20250514-v1:0:1m": { + "maxTokens": 8192, + "contextWindow": 1000000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "cacheWritesPrice": 18, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "cacheWritesPrice": 18, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "amazon.nova-premier-v1:0": { + "maxTokens": 10000, + "contextWindow": 1000000, + "inputPrice": 2, + "outputPrice": 12, + "supportsImages": true, + "supportsPromptCache": false + }, + "amazon.nova-pro-v1:0": { + "maxTokens": 5000, + "contextWindow": 300000, + "inputPrice": 0, + "outputPrice": 3, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "amazon.nova-lite-v1:0": { + "maxTokens": 5000, + "contextWindow": 300000, + "inputPrice": 0, + "outputPrice": 0, + "cacheWritesPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "amazon.nova-micro-v1:0": { + "maxTokens": 5000, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "cacheWritesPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": false, + "supportsPromptCache": true + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "cacheWritesPrice": 3, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 0, + "outputPrice": 4, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "maxTokens": 8192, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "supportsImages": true, + "supportsPromptCache": false + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "maxTokens": 4096, + "contextWindow": 200000, + "inputPrice": 15, + "outputPrice": 75, + "supportsImages": true, + "supportsPromptCache": false + }, + "anthropic.claude-3-sonnet-20240229-v1:0": { + "maxTokens": 4096, + "contextWindow": 200000, + "inputPrice": 3, + "outputPrice": 15, + "supportsImages": true, + "supportsPromptCache": false + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "maxTokens": 4096, + "contextWindow": 200000, + "inputPrice": 0, + "outputPrice": 1, + "supportsImages": true, + "supportsPromptCache": false + }, + "deepseek.r1-v1:0": { + "maxTokens": 8000, + "contextWindow": 64000, + "inputPrice": 1, + "outputPrice": 5, + "supportsImages": false, + "supportsPromptCache": false + }, + "openai.gpt-oss-120b-1:0": { + "maxTokens": 8192, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "A state-of-the-art 120B open-weight Mixture-of-Experts language model optimized for strong reasoning, tool use, and efficient deployment on large GPUs" + }, + "openai.gpt-oss-20b-1:0": { + "maxTokens": 8192, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "A compact 20B open-weight Mixture-of-Experts language model designed for strong reasoning and tool use, ideal for edge devices and local inference." + } + }, + "gemini": { + "gemini-2.5-pro": { + "maxTokens": 65536, + "contextWindow": 1048576, + "inputPrice": 2, + "outputPrice": 15, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gemini-2.5-flash-lite-preview-06-17": { + "maxTokens": 64000, + "contextWindow": 1000000, + "inputPrice": 0, + "outputPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true, + "description": "Preview version - may not be available in all regions" + }, + "gemini-2.5-flash": { + "maxTokens": 65536, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 2, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gemini-2.0-flash-001": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gemini-2.0-flash-lite-preview-02-05": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-2.0-pro-exp-02-05": { + "maxTokens": 8192, + "contextWindow": 2097152, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-2.0-flash-thinking-exp-01-21": { + "maxTokens": 65536, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-2.0-flash-thinking-exp-1219": { + "maxTokens": 8192, + "contextWindow": 32767, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-2.0-flash-exp": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-1.5-flash-002": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "cacheWritesPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gemini-1.5-flash-exp-0827": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-1.5-flash-8b-exp-0827": { + "maxTokens": 8192, + "contextWindow": 1048576, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-1.5-pro-002": { + "maxTokens": 8192, + "contextWindow": 2097152, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-1.5-pro-exp-0827": { + "maxTokens": 8192, + "contextWindow": 2097152, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + }, + "gemini-exp-1206": { + "maxTokens": 8192, + "contextWindow": 2097152, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": true, + "supportsPromptCache": false + } + }, + "openai-native": { + "gpt-5-2025-08-07": { + "maxTokens": 8192, + "contextWindow": 272000, + "inputPrice": 1, + "outputPrice": 10, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-5-mini-2025-08-07": { + "maxTokens": 8192, + "contextWindow": 272000, + "inputPrice": 0, + "outputPrice": 2, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-5-nano-2025-08-07": { + "maxTokens": 8192, + "contextWindow": 272000, + "inputPrice": 0, + "outputPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-5-chat-latest": { + "maxTokens": 8192, + "contextWindow": 400000, + "inputPrice": 1, + "outputPrice": 10, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "o4-mini": { + "maxTokens": 100000, + "contextWindow": 200000, + "inputPrice": 1, + "outputPrice": 4, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-4.1": { + "maxTokens": 32768, + "contextWindow": 1047576, + "inputPrice": 2, + "outputPrice": 8, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-4.1-mini": { + "maxTokens": 32768, + "contextWindow": 1047576, + "inputPrice": 0, + "outputPrice": 1, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-4.1-nano": { + "maxTokens": 32768, + "contextWindow": 1047576, + "inputPrice": 0, + "outputPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "o3-mini": { + "maxTokens": 100000, + "contextWindow": 200000, + "inputPrice": 1, + "outputPrice": 4, + "cacheReadsPrice": 0, + "supportsImages": false, + "supportsPromptCache": true + }, + "o1-preview": { + "maxTokens": 32768, + "contextWindow": 128000, + "inputPrice": 15, + "outputPrice": 60, + "cacheReadsPrice": 7, + "supportsImages": true, + "supportsPromptCache": true + }, + "o1-mini": { + "maxTokens": 65536, + "contextWindow": 128000, + "inputPrice": 1, + "outputPrice": 4, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-4o": { + "maxTokens": 4096, + "contextWindow": 128000, + "inputPrice": 2, + "outputPrice": 10, + "cacheReadsPrice": 1, + "supportsImages": true, + "supportsPromptCache": true + }, + "gpt-4o-mini": { + "maxTokens": 16384, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "chatgpt-4o-latest": { + "maxTokens": 16384, + "contextWindow": 128000, + "inputPrice": 5, + "outputPrice": 15, + "supportsImages": true, + "supportsPromptCache": false + } + }, + "xai": { + "grok-4-fast-reasoning": { + "maxTokens": 30000, + "contextWindow": 2000000, + "inputPrice": 0, + "outputPrice": 0, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": false, + "description": "xAI's Grok 4 Fast (free) multimodal model with 2M context." + }, + "grok-4": { + "maxTokens": 8192, + "contextWindow": 262144, + "inputPrice": 3, + "outputPrice": 15, + "cacheReadsPrice": 0, + "supportsImages": true, + "supportsPromptCache": true + }, + "grok-3-beta": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 3, + "outputPrice": 15, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 beta model with 131K context window" + }, + "grok-3-fast-beta": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 5, + "outputPrice": 25, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 fast beta model with 131K context window" + }, + "grok-3-mini-beta": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 mini beta model with 131K context window" + }, + "grok-3-mini-fast-beta": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 0, + "outputPrice": 4, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 mini fast beta model with 131K context window" + }, + "grok-3": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 3, + "outputPrice": 15, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 model with 131K context window" + }, + "grok-3-fast": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 5, + "outputPrice": 25, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 fast model with 131K context window" + }, + "grok-3-mini": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 mini model with 131K context window" + }, + "grok-3-mini-fast": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 0, + "outputPrice": 4, + "supportsImages": false, + "supportsPromptCache": true, + "description": "X AI's Grok-3 mini fast model with 131K context window" + }, + "grok-2-latest": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": false, + "supportsPromptCache": false, + "description": "X AI's Grok-2 model - latest version with 131K context window" + }, + "grok-2": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": false, + "supportsPromptCache": false, + "description": "X AI's Grok-2 model with 131K context window" + }, + "grok-2-1212": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": false, + "supportsPromptCache": false, + "description": "X AI's Grok-2 model (version 1212) with 131K context window" + }, + "grok-2-vision-latest": { + "maxTokens": 8192, + "contextWindow": 32768, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": true, + "supportsPromptCache": false, + "description": "X AI's Grok-2 Vision model - latest version with image support and 32K context window" + }, + "grok-2-vision": { + "maxTokens": 8192, + "contextWindow": 32768, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": true, + "supportsPromptCache": false, + "description": "X AI's Grok-2 Vision model with image support and 32K context window" + }, + "grok-2-vision-1212": { + "maxTokens": 8192, + "contextWindow": 32768, + "inputPrice": 2, + "outputPrice": 10, + "supportsImages": true, + "supportsPromptCache": false, + "description": "X AI's Grok-2 Vision model (version 1212) with image support and 32K context window" + }, + "grok-vision-beta": { + "maxTokens": 8192, + "contextWindow": 8192, + "inputPrice": 5, + "outputPrice": 15, + "supportsImages": true, + "supportsPromptCache": false, + "description": "X AI's Grok Vision Beta model with image support and 8K context window" + }, + "grok-beta": { + "maxTokens": 8192, + "contextWindow": 131072, + "inputPrice": 5, + "outputPrice": 15, + "supportsImages": false, + "supportsPromptCache": false, + "description": "X AI's Grok Beta model (legacy) with 131K context window" + } + }, + "cerebras": { + "gpt-oss-120b": { + "maxTokens": 65536, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "Intelligent general purpose model with 3,000 tokens/s" + }, + "qwen-3-coder-480b-free": { + "maxTokens": 40000, + "contextWindow": 64000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "SOTA coding model with ~2000 tokens/s ($0 free tier)\\n\\n• Use this if you don't have a Cerebras subscription\\n• 64K context window\\n• Rate limits: 150K TPM, 1M TPH/TPD, 10 RPM, 100 RPH/RPD\\n\\nUpgrade for higher limits: [https://cloud.cerebras.ai/?utm=cline](https://cloud.cerebras.ai/?utm=cline)" + }, + "qwen-3-coder-480b": { + "maxTokens": 40000, + "contextWindow": 128000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "SOTA coding model with ~2000 tokens/s ($50/$250 paid tiers)\\n\\n• Use this if you have a Cerebras subscription\\n• 131K context window with higher rate limits" + }, + "qwen-3-235b-a22b-instruct-2507": { + "maxTokens": 64000, + "contextWindow": 64000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "Intelligent model with ~1400 tokens/s" + }, + "llama-3.3-70b": { + "maxTokens": 64000, + "contextWindow": 64000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "Powerful model with ~2600 tokens/s" + }, + "qwen-3-32b": { + "maxTokens": 64000, + "contextWindow": 64000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "SOTA coding performance with ~2500 tokens/s" + }, + "qwen-3-235b-a22b-thinking-2507": { + "maxTokens": 32000, + "contextWindow": 65000, + "inputPrice": 0, + "outputPrice": 0, + "supportsImages": false, + "supportsPromptCache": false, + "description": "SOTA performance with ~1500 tokens/s" + } + } + }` + +// GetConfigFields returns all configuration fields +func GetConfigFields() ([]ConfigField, error) { + var fields []ConfigField + if err := json.Unmarshal([]byte(rawConfigFields), &fields); err != nil { + return nil, fmt.Errorf("failed to parse config fields: %w", err) + } + return fields, nil +} + +// GetModelDefinitions returns all model definitions +func GetModelDefinitions() (map[string]map[string]ModelInfo, error) { + var models map[string]map[string]ModelInfo + if err := json.Unmarshal([]byte(rawModelDefinitions), &models); err != nil { + return nil, fmt.Errorf("failed to parse model definitions: %w", err) + } + return models, nil +} + +// GetProviderDefinition returns the definition for a specific provider +func GetProviderDefinition(providerID string) (*ProviderDefinition, error) { + definitions, err := GetProviderDefinitions() + if err != nil { + return nil, err + } + + def, exists := definitions[providerID] + if !exists { + return nil, fmt.Errorf("provider %s not found", providerID) + } + + return &def, nil +} + +// GetProviderDefinitions returns all provider definitions +func GetProviderDefinitions() (map[string]ProviderDefinition, error) { + configFields, err := GetConfigFields() + if err != nil { + return nil, err + } + + modelDefinitions, err := GetModelDefinitions() + if err != nil { + return nil, err + } + + definitions := make(map[string]ProviderDefinition) + + // Anthropic (Claude) + definitions["anthropic"] = ProviderDefinition{ + ID: "anthropic", + Name: "Anthropic (Claude)", + RequiredFields: getFieldsByProvider("anthropic", configFields, true), + OptionalFields: getFieldsByProvider("anthropic", configFields, false), + Models: modelDefinitions["anthropic"], + DefaultModelID: "claude-sonnet-4-5-20250929", + HasDynamicModels: false, + SetupInstructions: `Get your API key from https://console.anthropic.com/`, + } + + // OpenRouter + definitions["openrouter"] = ProviderDefinition{ + ID: "openrouter", + Name: "OpenRouter", + RequiredFields: getFieldsByProvider("openrouter", configFields, true), + OptionalFields: getFieldsByProvider("openrouter", configFields, false), + Models: modelDefinitions["openrouter"], + DefaultModelID: "", + HasDynamicModels: true, + SetupInstructions: `Get your API key from https://openrouter.ai/keys`, + } + + // AWS Bedrock + definitions["bedrock"] = ProviderDefinition{ + ID: "bedrock", + Name: "AWS Bedrock", + RequiredFields: getFieldsByProvider("bedrock", configFields, true), + OptionalFields: getFieldsByProvider("bedrock", configFields, false), + Models: modelDefinitions["bedrock"], + DefaultModelID: "anthropic.claude-sonnet-4-20250514-v1", + HasDynamicModels: false, + SetupInstructions: `Configure AWS credentials with Bedrock access permissions`, + } + + // OpenAI Compatible + definitions["openai"] = ProviderDefinition{ + ID: "openai", + Name: "OpenAI Compatible", + RequiredFields: getFieldsByProvider("openai", configFields, true), + OptionalFields: getFieldsByProvider("openai", configFields, false), + Models: modelDefinitions["openai"], + DefaultModelID: "", + HasDynamicModels: true, + SetupInstructions: `Get your API key from https://platform.openai.com/api-keys`, + } + + // Ollama + definitions["ollama"] = ProviderDefinition{ + ID: "ollama", + Name: "Ollama", + RequiredFields: getFieldsByProvider("ollama", configFields, true), + OptionalFields: getFieldsByProvider("ollama", configFields, false), + Models: modelDefinitions["ollama"], + DefaultModelID: "", + HasDynamicModels: true, + SetupInstructions: `Install Ollama locally and ensure it's running on the specified port`, + } + + // Google Gemini + definitions["gemini"] = ProviderDefinition{ + ID: "gemini", + Name: "Google Gemini", + RequiredFields: getFieldsByProvider("gemini", configFields, true), + OptionalFields: getFieldsByProvider("gemini", configFields, false), + Models: modelDefinitions["gemini"], + DefaultModelID: "gemini-2.5-pro", + HasDynamicModels: false, + SetupInstructions: `Get your API key from https://makersuite.google.com/app/apikey`, + } + + // OpenAI + definitions["openai-native"] = ProviderDefinition{ + ID: "openai-native", + Name: "OpenAI", + RequiredFields: getFieldsByProvider("openai-native", configFields, true), + OptionalFields: getFieldsByProvider("openai-native", configFields, false), + Models: modelDefinitions["openai-native"], + DefaultModelID: "gpt-5-chat-latest", + HasDynamicModels: true, + SetupInstructions: `Get your API key from your API provider`, + } + + // X AI (Grok) + definitions["xai"] = ProviderDefinition{ + ID: "xai", + Name: "X AI (Grok)", + RequiredFields: getFieldsByProvider("xai", configFields, true), + OptionalFields: getFieldsByProvider("xai", configFields, false), + Models: modelDefinitions["xai"], + DefaultModelID: "grok-4", + HasDynamicModels: false, + SetupInstructions: `Get your API key from https://console.x.ai/`, + } + + // Cerebras + definitions["cerebras"] = ProviderDefinition{ + ID: "cerebras", + Name: "Cerebras", + RequiredFields: getFieldsByProvider("cerebras", configFields, true), + OptionalFields: getFieldsByProvider("cerebras", configFields, false), + Models: modelDefinitions["cerebras"], + DefaultModelID: "qwen-3-coder-480b-free", + HasDynamicModels: false, + SetupInstructions: `Get your API key from https://cloud.cerebras.ai/`, + } + + // Oca + definitions["oca"] = ProviderDefinition{ + ID: "oca", + Name: "Oca", + RequiredFields: getFieldsByProvider("oca", configFields, true), + OptionalFields: getFieldsByProvider("oca", configFields, false), + Models: modelDefinitions["oca"], + DefaultModelID: "", + HasDynamicModels: false, + SetupInstructions: `Configure Oca API credentials`, + } + + return definitions, nil +} + +// IsValidProvider checks if a provider ID is valid +func IsValidProvider(providerID string) bool { + for _, p := range AllProviders { + if p == providerID { + return true + } + } + return false +} + +// GetProviderDisplayName returns a human-readable name for a provider +func GetProviderDisplayName(providerID string) string { + displayNames := map[string]string{ + "anthropic": "Anthropic (Claude)", + "openrouter": "OpenRouter", + "bedrock": "AWS Bedrock", + "openai": "OpenAI Compatible", + "ollama": "Ollama", + "gemini": "Google Gemini", + "openai-native": "OpenAI", + "xai": "X AI (Grok)", + "cerebras": "Cerebras", + "oca": "Oca", + } + + if name, exists := displayNames[providerID]; exists { + return name + } + return providerID +} + +// getFieldsByProvider filters configuration fields by provider and requirement +// Uses category field as primary filter with override support +func getFieldsByProvider(providerID string, allFields []ConfigField, required bool) []ConfigField { + var fields []ConfigField + + for _, field := range allFields { + fieldName := strings.ToLower(field.Name) + fieldCategory := strings.ToLower(field.Category) + providerName := strings.ToLower(providerID) + + isRelevant := false + + // Priority 1: Check manual overrides FIRST (from GetFieldOverride in this package) + if override, hasOverride := GetFieldOverride(providerID, field.Name); hasOverride { + isRelevant = override + } else if fieldCategory == providerName { + // Priority 2: Direct category match (primary filtering mechanism) + isRelevant = true + } else if fieldCategory == "aws" && providerID == "bedrock" { + // Priority 3: Handle provider-specific category relationships + // AWS fields are used by Bedrock provider + isRelevant = true + } else if fieldCategory == "openai" && providerID == "openai-native" { + // OpenAI fields used by openai-native + isRelevant = true + } else if fieldCategory == "general" { + // Priority 4: Universal fields that apply to all providers + // Note: ulid is excluded as it's auto-generated and users should not set it + universalFields := []string{"requesttimeoutms", "clineaccountid"} + for _, universal := range universalFields { + if fieldName == universal { + isRelevant = true + break + } + } + } + + if isRelevant && field.Required == required { + fields = append(fields, field) + } + } + + return fields +} diff --git a/cli/pkg/hostbridge/env.go b/cli/pkg/hostbridge/env.go index b72945c4576..61f67ba9156 100644 --- a/cli/pkg/hostbridge/env.go +++ b/cli/pkg/hostbridge/env.go @@ -3,9 +3,13 @@ package hostbridge import ( "context" "log" + "os" + "github.com/atotto/clipboard" + "github.com/cline/cli/pkg/cli/global" "github.com/cline/grpc-go/cline" "github.com/cline/grpc-go/host" + "google.golang.org/protobuf/proto" ) // Global shutdown channel - simple approach @@ -31,11 +35,17 @@ func NewEnvService(verbose bool) *EnvService { // ClipboardWriteText writes text to the system clipboard func (s *EnvService) ClipboardWriteText(ctx context.Context, req *cline.StringRequest) (*cline.Empty, error) { if s.verbose { - log.Printf("ClipboardWriteText called with: %s", req.GetValue()) + log.Printf("ClipboardWriteText called with text length: %d", len(req.GetValue())) + } + + err := clipboard.WriteAll(req.GetValue()) + if err != nil { + if s.verbose { + log.Printf("Failed to write to clipboard: %v", err) + } + // Don't fail if clipboard is not available (e.g., headless environment) } - // TODO: Implement actual clipboard functionality - // For now, just return success return &cline.Empty{}, nil } @@ -45,23 +55,17 @@ func (s *EnvService) ClipboardReadText(ctx context.Context, req *cline.EmptyRequ log.Printf("ClipboardReadText called") } - // TODO: Implement actual clipboard functionality - // For now, return empty string - return &cline.String{ - Value: "", - }, nil -} - -// GetMachineId returns a stable machine identifier for telemetry distinctId purposes -func (s *EnvService) GetMachineId(ctx context.Context, req *cline.EmptyRequest) (*cline.String, error) { - if s.verbose { - log.Printf("GetMachineId called") + text, err := clipboard.ReadAll() + if err != nil { + if s.verbose { + log.Printf("Failed to read from clipboard: %v", err) + } + // Return empty string if clipboard is not available + text = "" } - // TODO: Implement actual machine ID functionality - // For now, return empty string return &cline.String{ - Value: "", + Value: text, }, nil } @@ -71,9 +75,12 @@ func (s *EnvService) GetHostVersion(ctx context.Context, req *cline.EmptyRequest log.Printf("GetHostVersion called") } - // TODO: Implement actual host version functionality - // For now, return empty response - return &host.GetHostVersionResponse{}, nil + return &host.GetHostVersionResponse{ + Platform: proto.String("Cline CLI"), + Version: proto.String(""), + ClineType: proto.String("CLI"), + ClineVersion: proto.String(global.CliVersion), + }, nil } // Shutdown initiates a graceful shutdown of the host bridge service @@ -96,3 +103,64 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl return &cline.Empty{}, nil } + +// GetTelemetrySettings returns the telemetry settings for CLI mode +func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) { + if s.verbose { + log.Printf("GetTelemetrySettings called") + } + + // In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable + telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true" + + var setting host.Setting + if telemetryEnabled { + setting = host.Setting_ENABLED + } else { + setting = host.Setting_DISABLED + } + + return &host.GetTelemetrySettingsResponse{ + IsEnabled: setting, + }, nil +} + +// SubscribeToTelemetrySettings returns a stream of telemetry setting changes +// In CLI mode, telemetry settings don't change at runtime, so we just send +// the current state and keep the stream open +func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, stream host.EnvService_SubscribeToTelemetrySettingsServer) error { + if s.verbose { + log.Printf("SubscribeToTelemetrySettings called") + } + + // Send initial telemetry state + telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true" + + var setting host.Setting + if telemetryEnabled { + setting = host.Setting_ENABLED + } else { + setting = host.Setting_DISABLED + } + + event := &host.TelemetrySettingsEvent{ + IsEnabled: setting, + } + + if err := stream.Send(event); err != nil { + if s.verbose { + log.Printf("Failed to send telemetry settings event: %v", err) + } + return err + } + + // Keep stream open until context is cancelled + // (In CLI mode, settings don't change dynamically) + <-stream.Context().Done() + + if s.verbose { + log.Printf("SubscribeToTelemetrySettings stream closed") + } + + return nil +} diff --git a/cli/pkg/hostbridge/simple_workspace.go b/cli/pkg/hostbridge/simple_workspace.go index de4e357a75f..9eb9ce7fe06 100644 --- a/cli/pkg/hostbridge/simple_workspace.go +++ b/cli/pkg/hostbridge/simple_workspace.go @@ -63,3 +63,23 @@ func (s *SimpleWorkspaceService) GetDiagnostics(ctx context.Context, req *host.G FileDiagnostics: []*cline.FileDiagnostics{}, }, nil } + +// OpenProblemsPanel opens the problems panel - no-op for console implementation +func (s *SimpleWorkspaceService) OpenProblemsPanel(ctx context.Context, req *host.OpenProblemsPanelRequest) (*host.OpenProblemsPanelResponse, error) { + return &host.OpenProblemsPanelResponse{}, nil +} + +// OpenInFileExplorerPanel opens a file/folder in the file explorer - no-op for console implementation +func (s *SimpleWorkspaceService) OpenInFileExplorerPanel(ctx context.Context, req *host.OpenInFileExplorerPanelRequest) (*host.OpenInFileExplorerPanelResponse, error) { + return &host.OpenInFileExplorerPanelResponse{}, nil +} + +// OpenClineSidebarPanel opens the Cline sidebar panel - no-op for console implementation +func (s *SimpleWorkspaceService) OpenClineSidebarPanel(ctx context.Context, req *host.OpenClineSidebarPanelRequest) (*host.OpenClineSidebarPanelResponse, error) { + return &host.OpenClineSidebarPanelResponse{}, nil +} + +// OpenTerminalPanel opens the terminal panel - no-op for console implementation +func (s *SimpleWorkspaceService) OpenTerminalPanel(ctx context.Context, req *host.OpenTerminalRequest) (*host.OpenTerminalResponse, error) { + return &host.OpenTerminalResponse{}, nil +} diff --git a/docs/assets/Cline_Logo-complete_black.png b/docs/assets/Cline_Logo-complete_black.png new file mode 100644 index 00000000000..32aae1ecee0 Binary files /dev/null and b/docs/assets/Cline_Logo-complete_black.png differ diff --git a/docs/assets/Cline_Logo-complete_white.png b/docs/assets/Cline_Logo-complete_white.png new file mode 100644 index 00000000000..2aa18f21816 Binary files /dev/null and b/docs/assets/Cline_Logo-complete_white.png differ diff --git a/docs/cline-cli/cli-reference.mdx b/docs/cline-cli/cli-reference.mdx new file mode 100644 index 00000000000..a2f36f027be --- /dev/null +++ b/docs/cline-cli/cli-reference.mdx @@ -0,0 +1,403 @@ +--- +title: "CLI Reference" +description: "Complete command reference for Cline CLI including configuration, instance management, and task commands" +--- + +Complete command reference for Cline CLI. Use this for detailed documentation on all commands, options, and configuration. + +For quick help in your terminal: + +```bash +cline --help # Show all commands +cline task --help # Show task-specific commands +man cline # View the full manual page +``` + +## Manual Page + +The complete manual page for the Cline CLI: + +``` +CLINE(1) User Commands CLINE(1) + +NAME + cline - orchestrate and interact with Cline AI coding agents + +SYNOPSIS + cline [prompt] [options] + + cline command [subcommand] [options] [arguments] + +DESCRIPTION + Try: cat README.md | cline "Summarize this for me:" + + cline is a command-line interface for orchestrating multiple Cline AI + coding agents. Cline is an autonomous AI agent who can read, write, + and execute code across your projects. He operates through a + client-server architecture where Cline Core runs as a standalone + service, and the CLI acts as a scriptable interface for managing tasks, + instances, and agent interactions. + + The CLI is designed for both interactive use and automation, making it + ideal for CI/CD pipelines, parallel task execution, and terminal-based + workflows. Multiple frontends (CLI, VSCode, JetBrains) can attach to + the same Cline Core instance, enabling seamless task handoff between + environments. + +MODES OF OPERATION + Instant Task Mode + The simplest invocation: cline "prompt here" immediately spawns + an instance, creates a task, and enters chat mode. This is + equivalent to running cline instance new && cline task new && + cline task chat in sequence. + + Subcommand Mode + Advanced usage with explicit control: cline + [subcommand] [options] provides fine-grained control over + instances, tasks, authentication, and configuration. + +AGENT BEHAVIOR + Cline operates in two primary modes: + + ACT MODE + Cline actively uses tools to accomplish tasks. He can read + files, write code, execute commands, use a headless browser, and + more. This is the default mode for task execution. + + PLAN MODE + Cline gathers information and creates a detailed plan before + implementation. He explores the codebase, asks clarifying + questions, and presents a strategy for user approval before + switching to ACT MODE. + +INSTANT TASK OPTIONS + When using the instant task syntax cline "prompt" the following options + are available: + + -o, --oneshot + Full autonomous mode. Cline completes the task and stops + following after completion. Example: cline -o "what's 6 + 8?" + + -s, --setting setting value + Override a setting for this task + + -y, --no-interactive, --yolo + Enable fully autonomous mode. Disables all interactivity: + + • ask_followup_question tool is disabled + + • attempt_completion happens automatically + + • execute_command runs in non-blocking mode with timeout + + • PLAN MODE automatically switches to ACT MODE + + -m, --mode mode + Starting mode. Options: act (default), plan + +GLOBAL OPTIONS + These options apply to all subcommands: + + -F, --output-format format + Output format. Options: rich (default), json, plain + + -h, --help + Display help information for the command. + + -v, --verbose + Enable verbose output for debugging. + +COMMANDS + Authentication + cline auth [provider] [key] + + cline a [provider] [key] + Configure authentication for AI model providers. Launches an + interactive wizard if no arguments provided. If provider is + specified without a key, prompts for the key or launches the + appropriate OAuth flow. + + Instance Management + Cline Core instances are independent agent processes that can run in + the background. Multiple instances can run simultaneously, enabling + parallel task execution. + + cline instance + + cline i + Display instance management help. + + cline instance new [-d|--default] + + cline i n [-d|--default] + Spawn a new Cline Core instance. Use --default to set it as + the default instance for subsequent commands. + + cline instance list + + cline i l + List all running Cline Core instances with their addresses and + status. + + cline instance default address + + cline i d address + Set the default instance to avoid specifying --address in task + commands. + + cline instance kill address [-a|--all] + + cline i k address [-a|--all] + Terminate a Cline Core instance. Use --all to kill all running + instances. + + Task Management + Tasks represent individual work items that Cline executes. Tasks + maintain conversation history, checkpoints, and settings. + + cline task [-a|--address ADDR] + + cline t [-a|--address ADDR] + Display task management help. The --address flag specifies + which Cline Core instance to use (e.g., localhost:50052). + + cline task new prompt [options] + + cline t n prompt [options] + Create a new task in the default or specified instance. + Options: + + -s, --setting setting value + Set task-specific settings + + -y, --no-interactive, --yolo + Enable autonomous mode + + -m, --mode mode + Starting mode (act or plan) + + cline task open task-id [options] + + cline t o task-id [options] + Resume a previous task from history. Accepts the same options + as task new. + + cline task list + + cline t l + List all tasks in history with their id and snippet + + cline task chat + + cline t c + Enter interactive chat mode for the current task. Allows + back-and-forth conversation with Cline. + + cline task send [message] [options] + + cline t s [message] [options] + Send a message to Cline. If no message is provided, reads from + stdin. Options: + + -a, --approve + Approve Cline's proposed action + + -d, --deny + Deny Cline's proposed action + + -f, --file FILE + Attach a file to the message + + -y, --no-interactive, --yolo + Enable autonomous mode + + -m, --mode mode + Switch mode (act or plan) + + cline task view [-f|--follow] [-c|--follow-complete] + + cline t v [-f|--follow] [-c|--follow-complete] + Display the current conversation. Use --follow to stream + updates in real-time, or --follow-complete to follow until task + completion. + + cline task restore checkpoint + + cline t r checkpoint + Restore the task to a previous checkpoint state. + + cline task pause + + cline t p + Pause task execution. + + Configuration + Configuration can be set globally. Override these global settings for + a task using the --setting flag + + cline config + + cline c + + cline config set key value + + cline c s key value + Set a configuration variable. + + cline config get key + + cline c g key + Read a configuration variable. + + cline config list + + cline c l + List all configuration variables and their values. + +TASK SETTINGS + Task settings are persisted in the ~/.cline/x/tasks directory. When + resuming a task with cline task open, task settings are automatically + restored. + + Common settings include: + + yolo Enable autonomous mode (true/false) + + mode Starting mode (act/plan) + +NOTES & EXAMPLES + The cline task send and cline task new commands support reading from + stdin, enabling powerful pipeline compositions: + + cat requirements.txt | cline task send + echo "Refactor this code" | cline -y + + Instance Management + Manage multiple Cline instances: + + # Start a new instance and make it default + cline instance new --default + + # List all running instances + cline instance list + + # Kill a specific instance + cline instance kill localhost:50052 + + # Kill all CLI instances + cline instance kill --all-cli + + Task History + Work with task history: + + # List previous tasks + cline task list + + # Resume a previous task + cline task open 1760501486669 + + # View conversation history + cline task view + + # Start interactive chat with this task + cline task chat + +ARCHITECTURE + Cline operates on a three-layer architecture: + + Presentation Layer + User interfaces (CLI, VSCode, JetBrains) that connect to Cline + Core via gRPC + + Cline Core + The autonomous agent service handling task management, AI model + integration, state management, tool orchestration, and real-time + streaming updates + + Host Provider Layer + Environment-specific integrations (VSCode APIs, JetBrains APIs, + shell APIs) that Cline Core uses to interact with the host + system + +BUGS + Report bugs at: + + For real-time help, join the Discord community at: + + +SEE ALSO + Full documentation: + +AUTHORS + Cline is developed by the Cline Bot Inc. and the open source community. + +COPYRIGHT + Copyright © 2025 Cline Bot Inc. Licensed under the Apache License 2.0. +``` + +### Shell Completion + +Generate autocompletion scripts for various shells: + +#### Bash + +```bash +# Generate bash completion +cline completion bash > /etc/bash_completion.d/cline + +# Or for user-level installation +cline completion bash > ~/.local/share/bash-completion/completions/cline +``` + +#### Zsh + +```bash +# Generate zsh completion +cline completion zsh > "${fpath[1]}/_cline" + +# Or add to your .zshrc +echo 'source <(cline completion zsh)' >> ~/.zshrc +``` + +#### Fish + +```bash +# Generate fish completion +cline completion fish > ~/.config/fish/completions/cline.fish +``` + +#### PowerShell + +```powershell +# Generate PowerShell completion +cline completion powershell > cline.ps1 + +# Add to your PowerShell profile +Add-Content $PROFILE "cline completion powershell | Out-String | Invoke-Expression" +``` + +### Version Command + +```bash +# Show version information +cline version +``` + +### Environment Variables + +#### CLINE_DIR + +Override the default Cline directory location: + +```bash +# Override default Cline directory +export CLINE_DIR=/custom/path + +# Default: ~/.cline +``` + +This directory is used for: +- Instance registry database +- Configuration files +- Task history +- Checkpoints diff --git a/docs/cline-cli/installation.mdx b/docs/cline-cli/installation.mdx new file mode 100644 index 00000000000..ad66084610c --- /dev/null +++ b/docs/cline-cli/installation.mdx @@ -0,0 +1,50 @@ +--- +title: "Installation" +description: "Install Cline CLI and authenticate with your account" +--- + +## Prerequisites + +Cline CLI requires Node.js version 20 or higher. We recommend using Node.js 22 for the best experience. + +To check your Node.js version: + +```bash +node --version +``` + +## Installation + +```bash +npm install -g cline +``` + +After installation, authenticate with your Cline account: + +```bash +cline auth +``` + +This starts an authentication wizard to sign you in and configure your preferred AI model provider. + +## Quick Start + +Get started with Cline in seconds: + +```bash +cline +``` + +That's it! Running `cline` in any directory starts an interactive session where you can chat with the AI agent. Type your task, review the plan, and type `/act` when ready to execute. + +For even faster execution without interaction: + +```bash +cline "Add unit tests to utils.js" +``` + +This runs Cline with a single command, perfect for quick tasks or automation. + + +New to Cline CLI? Start with interactive mode (`cline`) to see how it works. Once comfortable, explore [the three core flows](/cline-cli/three-core-flows) for advanced usage patterns. + diff --git a/docs/cline-cli/overview.mdx b/docs/cline-cli/overview.mdx new file mode 100644 index 00000000000..e63ed924288 --- /dev/null +++ b/docs/cline-cli/overview.mdx @@ -0,0 +1,70 @@ +--- +title: "Overview" +description: "Install the CLI, run your first task, and learn to automate code reviews and integrate AI agents into your development workflow" +--- + + +**Preview Release - macOS and Linux Only** + +Cline CLI is currently in preview and only available for macOS and Linux users. Windows support is coming soon. + + +## What is Cline CLI? + +Cline CLI runs AI coding agents directly in your terminal. Pipe git diffs for automated code reviews in CI/CD, run multiple instances simultaneously for parallel development, or integrate Cline into your existing shell workflows. + +The CLI tracks instances across your system and outputs in formats designed for both humans and scripts—JSON, plain text, or rich terminal output. + + +Ready to get started? Check out the [installation guide](/cline-cli/installation) to install Cline CLI and run your first task. + + +## Supported Model Providers + +Cline CLI supports multiple AI model providers, giving you flexibility in choosing the best model for your needs: + +- **Anthropic** +- **OpenAI** +- **OpenAI Compatible** +- **OpenRouter** +- **X AI (Grok)** +- **AWS Bedrock** +- **Google Gemini** +- **Ollama** +- **Cerebras** + +During installation, you'll authenticate and configure your preferred provider using the `cline auth` command. + +## What you can build with this + +**Automated code maintenance** +- Schedule daily runs to identify and fix linting issues across your codebase +- Create tasks that scan for security vulnerabilities and automatically patch them +- Build scripts that update deprecated dependencies and run tests + +**Multi-instance development** +- Run separate Cline instances for frontend and backend simultaneously +- Spawn instances for different feature branches, each with isolated state +- Create parallel review processes for multiple PRs + +**Custom workflows** +- Build shell scripts that combine Cline with git hooks for pre-commit analysis +- Create custom commands that pipe complex data structures through Cline for processing +- Integrate with your existing toolchain (jq, grep, awk) for sophisticated automation + +**CI/CD integration** +- Add Cline to GitHub Actions for automatic code review on every PR +- Create GitLab pipelines that generate migration scripts from schema changes +- Build Jenkins jobs that use Cline to analyze test failures and suggest fixes + +## Learn more + + + + Install Cline CLI and authenticate with your account to get started. + + + + Master the three ways to use Cline CLI: interactive mode, headless automation, and multi-instance parallelization. + + diff --git a/docs/cline-cli/three-core-flows.mdx b/docs/cline-cli/three-core-flows.mdx new file mode 100644 index 00000000000..19495a2c09f --- /dev/null +++ b/docs/cline-cli/three-core-flows.mdx @@ -0,0 +1,144 @@ +--- +title: "Three Core Flows" +description: "Learn the three ways to use Cline CLI: interactive mode, headless automation, and multi-instance parallelization" +--- + +Two concepts to understand: + +**Task** - A single job for Cline to complete ("add tests to utils.js"). You describe what you want, Cline plans how to do it, then executes the plan. Tasks run on instances. + +**Instance** - An independent Cline workspace. Each instance runs one task at a time. Create multiple instances to run multiple tasks that work on different parts of your project in parallel. + +## 1. Interactive mode: Plan first, then act + +Start here to see how Cline works. Interactive mode opens a chat session where you can review plans before execution. + +```bash +cline +``` + +Cline opens an interactive session in your current directory. Type your task as a message. Cline enters Plan mode and proposes a step-by-step strategy. + +Review or edit the plan in chat. When you're ready, switch to execution: + +```bash +/act +``` + +Cline executes the approved steps—reading files, writing code, running commands. You maintain control throughout the process. + +## 2. Headless single-shot: Complete a task without chat + +Use this for automation where you want a one-liner that just does the work. + +```bash +cline instance new --default +cline task new -y "Generate unit tests for all Go files" +``` + +With the `-y` (YOLO) flag, Cline plans and executes autonomously without interactive chat. Perfect for CI, cron jobs, or scripts. + +Examples: + +```bash +# Create a complete feature +cline task new -y "Create a REST API for user authentication" + +# Generate documentation +cline task new -y "Add JSDoc comments to all functions in src/" + +# Refactor code +cline task new -y "Convert all var declarations to const/let" +``` + +Monitor your task with: + +```bash +# View task status +cline task view + +# Follow task progress in real-time +cline task view --follow +``` + +Press Ctrl+C to exit the view. + + +Run YOLO mode with care on a directory or a clean Git branch. You get speed in exchange for oversight, so be ready to revert if needed. + + +## 3. Multi-instance: Run parallel agents + +Multiple instances let you parallelize work on the same project without colliding contexts. Run frontend, backend, and infrastructure tasks simultaneously. + +Create your first instance: + +```bash +cline instance new +``` + +This returns an instance address you'll use to target tasks. Attach a task to this instance: + +```bash +# Frontend work on first instance +cline task new -y "Build React components" +``` + +Create a second instance and set it as default in one command: + +```bash +cline instance new --default +``` + +Now you can create tasks without specifying the address—they automatically use the default instance: + +```bash +# Backend work on the new default instance +cline task new -y "Implement API endpoints" +``` + +List all running instances: + +```bash +cline instances list +``` + +Stop all instances when done: + +```bash +cline instances kill -a +``` + + +Keep track of instance addresses returned by `cline instance new`. When scripting multiple agents, store these IDs and direct your tasks to the appropriate instance. + + +## Choosing the right flow + +- **Interactive mode**: Best for exploring new problems, learning how Cline works, or when you want to review plans before execution +- **Headless single-shot**: Perfect for automation, CI/CD, and tasks where you trust Cline to execute without supervision +- **Multi-instance**: Use when you need to parallelize work or maintain separate contexts for different parts of your project + + +For in-depth commands and flags, check out the [CLI reference](/cline-cli/cli-reference) page for complete documentation on all available options. + + +## Next steps + + + + Complete command documentation including configuration, instance management, and task commands. + + + + Deep dive into Plan and Act modes, including when to use each and how to switch between them. + + + + Understand how YOLO mode works and when to use full automation versus manual approval. + + + + Learn how Cline tracks and manages tasks, including saving and restoring state from checkpoints. + + diff --git a/docs/core-features/model-selection-guide.mdx b/docs/core-features/model-selection-guide.mdx new file mode 100644 index 00000000000..e04898c7c26 --- /dev/null +++ b/docs/core-features/model-selection-guide.mdx @@ -0,0 +1,202 @@ +--- +title: "Model Selection Guide" +description: "Last updated: August 20, 2025." +--- + +New models drop constantly, so this guide focuses on what's working well with Cline right now. We'll keep it updated as the landscape shifts. + + +**New to model selection?** Start with [Module 2 of Cline's Learning Path](https://cline.bot/learn) for a comprehensive guide to choosing and configuring models. + + +## What is an AI Model? + +Think of an AI model as the "brain" that powers Cline. When you ask Cline to write code, fix bugs, or refactor your project, it's the model that actually understands your request and generates the response. + +**Key points:** +- **Models are trained AI systems** that understand natural language and code +- **Different models have different strengths** some excel at complex reasoning, others prioritize speed or cost +- **You choose which model Cline uses** like picking between different experts for different tasks +- **Models are accessed via API providers** - companies like Anthropic, OpenAI, and OpenRouter host these models + +**Why it matters:** The model you choose directly impacts Cline's capabilities, response quality, speed, and cost. A premium model might handle complex refactoring beautifully but cost more, while a budget model works great for routine tasks at a fraction of the price. + +## How to Select a Model in Cline + +Follow these 5 simple steps to get Cline up and running with your preferred AI model: + +### Step 1: Open Cline Settings + +First, you need to access Cline's configuration panel. + +**Two ways to open settings:** +- **Quick method**: Click the **gear icon (⚙️)** in the top-right corner of Cline's chat interface +- **Command palette**: Press **Cmd/Ctrl + Shift + P** → type "Cline: Open Settings" + + + Cline Settings Panel + + +The settings panel will open, showing configuration options with "API Provider" at the top. + + +The settings panel remembers your last configuration, so you'll only need to set this up once. + + +### Step 2: Select an API Provider + +Choose your preferred AI provider from the dropdown menu. + + + + Cline Settings Panel + + +**Popular providers at a glance:** + +| Provider | Best For | Notes | +|----------|----------|-------| +| **Cline** | Easiest setup | No API keys needed, access to multiple models including stealth models | +| **OpenRouter** | Value seekers | Multiple models, competitive pricing | +| **Anthropic** | Reliability | Claude models, most dependable tool usage | +| **OpenAI** | Latest tech | GPT models | +| **Google Gemini** | Large context | Google's AI models | +| **AWS Bedrock** | Enterprise | Advanced features | +| **Ollama** | Privacy | Run models locally | + +See the [full provider list](/provider-config) for more options including Cerebras, Vertex AI, Azure, and more. + + +**Recommended for beginners:** Start with **Cline** as your provider - no API key management needed, instant access to multiple models, and occasional free inferencing through partner providers. + + +### Step 3: Add Your API Key (or Sign In) + +The next step depends on which provider you selected. + +#### If you selected **Cline** as your provider: + +- **No API key needed!** Simply sign in with your Cline account +- Click the **Sign In** button when prompted +- You'll be redirected to [app.cline.bot](https://app.cline.bot) to authenticate +- After signing in, return to your IDE + +#### If you selected any other provider: + +You'll need to get an API key from your chosen provider: + +1. **Visit your provider's website to get an API key:** + - **Anthropic**: [console.anthropic.com](https://console.anthropic.com/) + - **OpenRouter**: [openrouter.ai/keys](https://openrouter.ai/keys) + - **OpenAI**: [platform.openai.com/api-keys](https://platform.openai.com/api-keys) + - **Google**: [aistudio.google.com/apikey](https://aistudio.google.com/apikey) + - **Others**: See [Provider Setup Guide](/provider-config) + +2. **Generate a new API key** on the provider's website + +3. **Copy the API key** to your clipboard + +4. **Paste your key** in the **"API Key"** field in Cline settings + +5. **Save automatically** - Your key is stored securely in your editor's secrets storage + + + Cline API Selection + + + +**Payment required for most providers**: Most providers need payment information before generating keys. You only pay for what you use (typically $0.01-$0.10 per coding task). + + +### Step 4: Choose Your Model + +Once your API key is added (or you've signed in), the **"Model"** dropdown becomes available. + + + Cline Model Selection + + +**Quick model selection guide:** + +| Your Priority | Choose This Model | Why | +|---------------|-------------------|-----| +| **Maximum reliability** | Claude Sonnet 4.5 | Most reliable tool usage, excellent at complex tasks | +| **Best value** | DeepSeek V3 or Qwen3 Coder | Great performance at budget prices | +| **Fastest speed** | Qwen3 Coder on Cerebras | Lightning-fast responses | +| **Run locally** | Any Ollama model | Complete privacy, no internet needed | +| **Latest features** | GPT-5 | OpenAI's newest capabilities | + +Not sure which to pick? Start with **Claude Sonnet 4.5** for reliability or **DeepSeek V3** for value. + + +You can switch models at any time without losing your conversation. Try different models to find what works best for your specific tasks. + + +See the [model comparison tables](#current-top-models) below for detailed specifications and pricing. + +### Step 5: Start Using Cline + +**Congratulations! You're all set up.** Here's how to start coding with Cline: + +1. **Type your request** in the Cline chat box + - Example: "Create a React component for a login form" + - Example: "Debug this TypeScript error" + - Example: "Refactor this function to be more efficient" + +2. **Press Enter** or click the send icon to submit + +## Choosing the Right Model + +Selecting the right model involves balancing several factors. Use this framework to find your ideal match: + + +**Pro tips**: Configure separate models for Plan Mode and Act Mode. Make the most out the each model's strengths. For example, use a budget model for planning discussions and a premium model for implementation. + + +### Key Selection Factors + +| Factor | What to Consider | Recommendation | +|--------|------------------|----------------| +| **Task Complexity** | Simple fixes vs complex refactoring | Budget models for routine tasks; Premium models for complex work | +| **Budget** | Monthly spending capacity | \$10-\$30: Budget, \$30-\$100: Mid-tier, \$100+: Premium | +| **Context Window** | Project size and file count | Small: 32K-128K, Medium: 128K-200K, Large: 400K+ | +| **Speed** | Response time requirements | Interactive: Fast models, Background: Reasoning models OK | +| **Tool Reliability** | Complex operations | Claude excels at tool usage; Test others with your workflow | +| **Provider** | Access and pricing needs | OpenRouter: Many options, Direct: Faster/reliable, Local: Privacy | + + + +## Model Comparison Resources + +For detailed model comparisons, pricing, and performance metrics, see: +- [**Model Comparison & Pricing**](/model-config/model-comparison) - Complete pricing tables and performance benchmarks +- [**Context Window Guide**](/model-config/context-windows) - Understanding and optimizing context usage + +## Open Source vs Closed Source + +### Open Source Advantages +- **Multiple providers** compete to host them +- **Cheaper pricing** due to competition +- **Provider choice** - switch if one goes down +- **Faster innovation** cycles + +### Open Source Models Available +- **Qwen3 Coder** (Apache 2.0) +- **Z AI GLM 4.5** (MIT) +- **Kimi K2** (Open source) +- **DeepSeek series** (Various licenses) + +## Quick Decision Matrix + +| If you want... | Use this | +|----------------|----------| +| Something that just works | Claude Sonnet 4.5 | +| To save money | DeepSeek V3 or Qwen3 variants | +| Huge context windows | Gemini 2.5 Pro or Claude Sonnet 4.5 | +| Open source | Qwen3 Coder, Z AI GLM 4.5, or Kimi K2 | +| Latest tech | GPT-5 | +| Speed | Qwen3 Coder on Cerebras (fastest available) | + +## What Others Are Using + +Check [OpenRouter's Cline usage stats](https://openrouter.ai/apps?url=https%3A%2F%2Fcline.bot%2F) to see real usage patterns from the community. diff --git a/docs/docs.json b/docs/docs.json index 30e317e0808..4f67722c5cd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2,15 +2,15 @@ "$schema": "https://mintlify.com/docs.json", "theme": "linden", "name": "Cline", - "description": "AI-powered coding assistant for VSCode", + "description": "AI-powered coding agent for complex work", "colors": { "primary": "#9D4EDD", "light": "#F0E6FF", "dark": "#000000" }, "logo": { - "light": "/assets/robot_panel_light.png", - "dark": "/assets/robot_panel_dark.png" + "light": "/assets/Cline_Logo-complete_black.png", + "dark": "/assets/Cline_Logo-complete_white.png" }, "favicon": { "light": "/assets/robot_panel_light.png", @@ -18,10 +18,9 @@ }, "background": { "color": { - "light": "#F0E6FF", - "dark": "#000000" - }, - "decoration": "gradient" + "light": "#fafaf9", + "dark": "#0f0f0f" + } }, "styling": { "eyebrows": "breadcrumbs", @@ -33,16 +32,18 @@ "strict": false }, "fonts": { - "family": "Roboto" + "family": "Geist Sans" }, "navbar": { "links": [ { "label": "GitHub", + "icon": "github", "href": "https://github.com/cline/cline" }, { "label": "Discord", + "icon": "discord", "href": "https://discord.gg/cline" } ], @@ -53,168 +54,210 @@ } }, "navigation": { - "groups": [ + "tabs": [ { - "group": "Getting Started", - "pages": [ - "getting-started/what-is-cline", - "getting-started/installing-cline", - "getting-started/model-selection-guide", - "getting-started/task-management", - "getting-started/understanding-context-management", + "tab": "Docs", + "icon": "square-terminal", + "groups": [ { - "group": "For New Coders", + "group": "Introduction", "pages": [ - "getting-started/for-new-coders", - "getting-started/installing-dev-essentials" + "introduction/welcome", + "introduction/overview" ] - } - ] - }, - { - "group": "Improving Your Prompting Skills", - "pages": [ - "prompting/prompt-engineering-guide", - "prompting/cline-memory-bank" - ] - }, - { - "group": "Features", - "pages": [ + }, { - "group": "@ Mentions", + "group": "Getting Started", "pages": [ - "features/at-mentions/overview", - "features/at-mentions/file-mentions", - "features/at-mentions/terminal-mentions", - "features/at-mentions/problem-mentions", - "features/at-mentions/git-mentions", - "features/at-mentions/url-mentions" + "getting-started/installing-cline", + "getting-started/selecting-your-model", + "getting-started/your-first-project" ] }, - "features/auto-approve", - "features/auto-compact", - "features/checkpoints", - "features/cline-rules", { - "group": "Commands & Shortcuts", + "group": "Best Practices", "pages": [ - "features/commands-and-shortcuts/overview", - "features/commands-and-shortcuts/code-commands", - "features/commands-and-shortcuts/terminal-integration", - "features/commands-and-shortcuts/git-integration", - "features/commands-and-shortcuts/keyboard-shortcuts" + "prompting/understanding-context-management", + "prompting/prompt-engineering-guide", + "prompting/cline-memory-bank" ] }, { - "group": "Customization", + "group": "CLI", "pages": [ - "features/customization/opening-cline-in-sidebar", - "features/customization/disable-terminal-pagers" + "cline-cli/overview", + "cline-cli/installation", + "cline-cli/three-core-flows", + "cline-cli/cli-reference" ] }, - "features/dictation", - "features/drag-and-drop", - "features/editing-messages", - "features/focus-chain", - "features/plan-and-act", { - "group": "Slash Commands", + "group": "Features", "pages": [ - "features/slash-commands/new-task", - "features/slash-commands/new-rule", - "features/slash-commands/smol", - "features/slash-commands/report-bug", - "features/slash-commands/deep-planning" + { + "group": "@ Mentions", + "pages": [ + "features/at-mentions/overview", + "features/at-mentions/file-mentions", + "features/at-mentions/terminal-mentions", + "features/at-mentions/problem-mentions", + "features/at-mentions/git-mentions", + "features/at-mentions/url-mentions" + ] + }, + "features/auto-approve", + "features/auto-compact", + "features/checkpoints", + "features/cline-rules", + { + "group": "Commands & Shortcuts", + "pages": [ + "features/commands-and-shortcuts/overview", + "features/commands-and-shortcuts/code-commands", + "features/commands-and-shortcuts/terminal-integration", + "features/commands-and-shortcuts/git-integration", + "features/commands-and-shortcuts/keyboard-shortcuts" + ] + }, + { + "group": "Customization", + "pages": [ + "features/customization/opening-cline-in-sidebar", + "features/customization/disable-terminal-pagers" + ] + }, + "features/dictation", + "features/drag-and-drop", + "features/editing-messages", + "features/focus-chain", + "features/multiroot-workspace", + "features/plan-and-act", + { + "group": "Slash Commands", + "pages": [ + "features/slash-commands/new-task", + "features/slash-commands/new-rule", + "features/slash-commands/smol", + "features/slash-commands/report-bug", + "features/slash-commands/deep-planning" + ] + }, + "features/slash-commands/workflows", + { + "group": "Task Management", + "pages": [ + "features/tasks/understanding-tasks", + "features/tasks/task-management" + ] + }, + "features/yolo-mode" ] }, - "features/slash-commands/workflows", - "features/yolo-mode" - ] - }, - { - "group": "Exploring Cline's Tools", - "pages": [ - "exploring-clines-tools/cline-tools-guide", - "exploring-clines-tools/new-task-tool", - "exploring-clines-tools/remote-browser-support" - ] - }, - { - "group": "Enterprise Solutions", - "pages": [ - "enterprise-solutions/cloud-provider-integration", - "enterprise-solutions/custom-instructions", - "enterprise-solutions/mcp-servers", - "enterprise-solutions/security-concerns" - ] - }, - { - "group": "MCP Servers", - "pages": [ - "mcp/mcp-overview", - "mcp/adding-mcp-servers-from-github", - "mcp/configuring-mcp-servers", - "mcp/connecting-to-a-remote-server", - "mcp/mcp-marketplace", - "mcp/mcp-server-development-protocol", - "mcp/mcp-transport-mechanisms" - ] - }, - { - "group": "Provider Configuration", - "pages": [ - "provider-config/anthropic", - "provider-config/claude-code", { - "group": "AWS Bedrock", + "group": "Model & Provider Configuration", "pages": [ - "provider-config/aws-bedrock/api-key", - "provider-config/aws-bedrock/iam-credentials", - "provider-config/aws-bedrock/cli-profile" + { + "group": "Model Selection", + "pages": [ + "core-features/model-selection-guide", + "model-config/model-comparison", + "model-config/context-windows" + ] + }, + { + "group": "Cloud Providers", + "pages": [ + "provider-config/anthropic", + "provider-config/claude-code", + "provider-config/openai", + "provider-config/openrouter", + "provider-config/cerebras", + "provider-config/deepseek", + "provider-config/groq", + "provider-config/xai-grok", + "provider-config/mistral-ai", + "provider-config/doubao", + "provider-config/fireworks", + "provider-config/zai", + "provider-config/gcp-vertex-ai", + { + "group": "AWS Bedrock", + "pages": [ + "provider-config/aws-bedrock/api-key", + "provider-config/aws-bedrock/iam-credentials", + "provider-config/aws-bedrock/cli-profile" + ] + } + ] + }, + { + "group": "Running Models Locally", + "pages": [ + "running-models-locally/overview", + "running-models-locally/ollama", + "running-models-locally/lm-studio" + ] + }, + { + "group": "Advanced Configuration", + "pages": [ + "provider-config/openai-compatible", + "provider-config/litellm-and-cline-using-codestral", + "provider-config/vscode-language-model-api", + "provider-config/sap-aicore", + "provider-config/vercel-ai-gateway", + "provider-config/requesty", + "provider-config/baseten" + ] + } ] }, - "provider-config/gcp-vertex-ai", - "provider-config/litellm-and-cline-using-codestral", - "provider-config/vscode-language-model-api", - "provider-config/xai-grok", - "provider-config/mistral-ai", - "provider-config/deepseek", - "provider-config/groq", - "provider-config/cerebras", - "provider-config/doubao", - "provider-config/fireworks", - "provider-config/zai", - "provider-config/ollama", - "provider-config/openai", - "provider-config/openai-compatible", - "provider-config/openrouter", - "provider-config/sap-aicore", - "provider-config/vercel-ai-gateway", - "provider-config/requesty", - "provider-config/baseten" - ] - }, - { - "group": "Running Models Locally", - "pages": [ - "running-models-locally/read-me-first", - "running-models-locally/lm-studio", - "running-models-locally/ollama" + { + "group": "MCP Integration", + "pages": [ + "mcp/mcp-overview", + "mcp/adding-mcp-servers-from-github", + "mcp/configuring-mcp-servers", + "mcp/connecting-to-a-remote-server", + "mcp/mcp-marketplace", + "mcp/mcp-server-development-protocol", + "mcp/mcp-transport-mechanisms" + ] + }, + { + "group": "Cline Tools Reference", + "pages": [ + "exploring-clines-tools/cline-tools-guide", + "exploring-clines-tools/new-task-tool", + "exploring-clines-tools/remote-browser-support" + ] + }, + { + "group": "Enterprise", + "pages": [ + "enterprise-solutions/overview", + "enterprise-solutions/security-concerns" + ] + }, + { + "group": "Reference", + "pages": [ + "troubleshooting/terminal-quick-fixes", + "troubleshooting/terminal-integration-guide", + "more-info/telemetry" + ] + } ] }, { - "group": "Troubleshooting", - "pages": [ - "troubleshooting/terminal-quick-fixes", - "troubleshooting/terminal-integration-guide" - ] + "tab": "Learn", + "icon": "graduation-cap", + "href": "https://cline.bot/learn" }, { - "group": "More Info", - "pages": [ - "more-info/telemetry" - ] + "tab": "Blog", + "icon": "newspaper", + "href": "https://cline.bot/blog" } ] }, @@ -227,23 +270,54 @@ }, "anchors": [ { - "name": "What is Cline", + "name": "Overview", "icon": "house", - "url": "getting-started/what-is-cline" + "url": "introduction/overview" } ], "redirects": [ { "source": "/getting-started/installing-cline-jetbrains", "destination": "/getting-started/installing-cline" + }, + { + "source": "/getting-started/what-is-cline", + "destination": "/introduction/overview" + }, + { + "source": "/getting-started/overview", + "destination": "/introduction/overview" + }, + { + "source": "/introduction", + "destination": "/introduction/welcome" + }, + { + "source": "/getting-started/model-selection-guide", + "destination": "/core-features/model-selection-guide" + }, + { + "source": "/provider-config/ollama", + "destination": "/running-models-locally/ollama" + }, + { + "source": "/running-models-locally/read-me-first", + "destination": "/running-models-locally/overview" + }, + { + "source": "/getting-started/understanding-context-management", + "destination": "/prompting/understanding-context-management" + }, + { + "source": "/best-practices/understanding-context-management", + "destination": "/prompting/understanding-context-management" + }, + { + "source": "/getting-started/your-first-task", + "destination": "/getting-started/your-first-project" } ], "search": { "prompt": "Search Cline documentation..." - }, - "contextual": { - "options": [ - "copy" - ] } } diff --git a/docs/enterprise-solutions/cloud-provider-integration.mdx b/docs/enterprise-solutions/cloud-provider-integration.mdx deleted file mode 100644 index da605c26e6d..00000000000 --- a/docs/enterprise-solutions/cloud-provider-integration.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: "Cloud Provider Integration" ---- - -Cline supports major cloud providers like AWS Bedrock and Google's Cloud Vertex; whichever your team currently uses is appropriate, and there's no need to change providers to utilize Cline's features. - -For the purpose of this document, we assume your organization will use cloud-based frontier models. Cloud inference providers offer cutting-edge capabilities and the flexibility to select models which best suit your needs. - -Certain scenarios may warrant using local models, including handling highly sensitive data, applications requiring consistent low-latency responses, or compliance with strict data sovereignty requirements. If your team needs to utilize local models, see [Running Local Models ](/running-models-locally/read-me-first.mdx)with Cline. - ---- - -## AWS Bedrock Setup Guides - -#### [IAM Security Best Practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) (For administrators) - -#### [AWS Bedrock setup for API Keys](/provider-config/aws-bedrock-with-apikey-authentication) - -#### [AWS Bedrock setup for Legacy IAM (AWS Credentials)](/provider-config/aws-bedrock-with-credentials-authentication) - -#### [AWS Bedrock setup for SSO token (AWS Profile)](/provider-config/aws-bedrock-with-profile-authentication) - -#### VPC Endpoint Setup - -To protect your team's data, Cline supports VPC (Virtual Private Cloud) endpoints, which create private connections between your data and AWS Bedrock. AWS VPCs enhance security by eliminating the need for public IP addresses, network gateways, or complex firewall rules—essentially creating a private highway for data that bypasses the public internet entirely. By keeping traffic within AWS's private network, teams also benefit from lower latency and more predictable performance when accessing services like AWS Bedrock or custom APIs. For those working with confidential information or operating in highly regulated industries like healthcare or finance, VPCs offers the perfect balance between the accessibility of cloud services and the security of private infrastructure. - ---- - -1. Consult the [AWS guide](https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html) to creating VPC endpoints. This document specifies pre-requisites and describes the syntax used for creating VPC endpoints. -2. Follow the directions for [creating a VPC endpoint](https://docs.aws.amazon.com/vpc/latest/privatelink/create-interface-endpoint.html#create-interface-endpoint-aws) in the AWS console. The image below pertains to steps 4 and 5 of the AWS guide linked above. - - - VPC Console - - -3. Note the IP address of your VPC endpoint, open Cline's settings menu, and select `AWS Bedrock`from the API Provider dropdown. -4. Click the `Use Custom VPC endpoint`checkbox and enter the IP address of your VPC endpoint - - - VPC Settings Menu - diff --git a/docs/enterprise-solutions/custom-instructions.mdx b/docs/enterprise-solutions/custom-instructions.mdx deleted file mode 100644 index 8096d464ff9..00000000000 --- a/docs/enterprise-solutions/custom-instructions.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Custom Instructions" ---- - -## Building Custom Instructions for Teams - -**Creating standardized project instructions ensures that all team members work within consistent guidelines. Start by documenting your project's technical foundation, then identify which information needs to be included in the instructions. The exact scope will vary depending on your team's needs, but generally it's best to provide as much information as possible. By creating comprehensive instructions that all team members follow, you establish a shared understanding of how code should be written, tested, and deployed across your project, resulting in more maintainable and consistent software.** - ---- - -Here are a few topics and examples to consider for your team's custom instructions: - -1. **Testing framework and specific commands** - - "All components must include Jest tests with at least 85% coverage. Run tests using `npm run test:coverage` before submitting any pull request." -2. **Explicit library preferences** - - "Use React Query for data fetching and state management. Avoid Redux unless specifically required for complex global state. For styling, use Tailwind CSS with our custom theme configuration found in `src/styles/theme.js.`" -3. **Where to find documentation** - - "All API documentation is available in our internal Notion workspace under 'Engineering > API Reference'. For component usage examples, refer to our Storybook instance at `https://storybook.internal.company.com`" -4. **Which MCP servers to use, and for which purposes** - - "For database operations, use the Postgres MCP server with credentials stored in 1Password under 'Development > Database'. For deployments, use the AWS MCP server which requires the deployment role from IAM. Refer to `docs/mcp-setup.md` for configuration instructions." -5. **Coding conventions specific to your project** - - "Name all React components using PascalCase and all helper functions using camelCase. Place components in the `src/components` directory organized by feature, not by type. Always use TypeScript interfaces for prop definitions." diff --git a/docs/enterprise-solutions/mcp-servers.mdx b/docs/enterprise-solutions/mcp-servers.mdx deleted file mode 100644 index 2e8101cc097..00000000000 --- a/docs/enterprise-solutions/mcp-servers.mdx +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: "MCP Servers" ---- - -**Model Context Protocol (MCP) servers expand Cline's capabilities by providing standardized access to external data sources and executable functions. By implementing MCP servers, LLM tools can dynamically retrieve and incorporate relevant information from both local and remote data sources. This capability ensures that the models operate with the most current and contextually appropriate data, improving the accuracy and relevance of their outputs.** - ---- - -### Secure Architecture Fundamentals - -MCP servers follow a client-server architecture where hosts (LLM applications like Cline) initiate connections through a transport layer to MCP servers. This architecture inherently provides security benefits as it maintains clear separation between components. Enterprise deployments should focus on the proper implementation of this architecture to ensure secure operations, particularly regarding the message exchange patterns and connection lifecycle management. For MCP architecture details, see [MCP Architecture](https://modelcontextprotocol.io/docs/concepts/architecture), and for latest specifications, see [MCP Specifications](https://spec.modelcontextprotocol.io/specification/2024-11-05/). - -### Transport Layer Security - -For enterprise environments, selecting the appropriate transport mechanism is crucial. While stdio transport works efficiently for local processes, HTTP with Server-Sent Events (SSE) transport requires additional security measures. TLS should be used for all remote connections whenever possible. This is especially important when MCP servers are deployed across different network segments within corporate infrastructure. - -### Message Validation and Access Control - -The MCP architecture defines standard error codes and message types (Requests, Results, Errors, and Notifications), providing a structured framework for secure communication. Security teams should consider message validation, sanitizing inputs, checking message size limits, and verifying JSON-RPC format. Additionally, implementing resource protection through access controls, path validation, and request rate limiting helps prevent potential abuse of MCP server capabilities. - -### Monitoring and Compliance - -For enterprise compliance requirements, implementing comprehensive logging of protocol events, message flows, and errors is essential. The MCP architecture supports diagnostic capabilities including health checks, connection state monitoring, and resource usage tracking. Organizations should extend these capabilities to meet their specific compliance needs, particularly for audit trails of all MCP server interactions and resource access patterns. - -By leveraging the client-server design of the MCP architecture and implementing appropriate security controls at each layer, enterprises can safely integrate MCP servers into their environments while maintaining their security posture and meeting regulatory requirements. diff --git a/docs/enterprise-solutions/overview.mdx b/docs/enterprise-solutions/overview.mdx new file mode 100644 index 00000000000..085cbdcdffd --- /dev/null +++ b/docs/enterprise-solutions/overview.mdx @@ -0,0 +1,95 @@ +--- +title: "Cline Enterprise" +sidebarTitle: "Overview" +description: "Enterprise security, governance, and observability for the coding agent 3 million developers trust" +--- + +Cline Enterprise brings centralized governance to the same open-source architecture that millions of developers already use. Your code stays in your environment, you use your own inference at your negotiated rates, and you get the security and observability capabilities that platform teams need for org-wide deployment. + + + Visit our website for detailed information about enterprise features, pricing, and deployment options. + + +## What You Get + +It delivers five core capabilities that platform teams need for production deployment. Each addresses a specific requirement for scaling AI coding across your organization. + +### Security by Design + +Your code never leaves your environment. Cline processes everything locally - no uploads, no indexing, no training on your data. + + + + All processing happens within your environment + + + + Code and context never transmitted externally + + + + Repositories are never indexed or cached + + + + Your code and prompts aren't used for training + + + +### Bring Your Own Inference + +Use your existing cloud contracts and negotiated rates. Most AI tools force you to buy inference through them with markup. Cline connects directly to your providers. + +Connect to any inference provider: +- AWS Bedrock +- Google Vertex AI +- Azure OpenAI +- Anthropic direct +- OpenAI direct +- Cerebras +- Any OpenAI-compatible endpoint + +Switch models instantly as new ones release. Use Claude Sonnet 4.5 as your daily driver, GPT-5 for complex refactoring, open-source models for simple tasks. Your existing cloud credits and startup program contracts now cover AI coding. We handle the agent loop. You handle the inference. No markup, no vendor lock-in. + +### Governance at Scale + +Platform teams need central control when thousands of developers use AI. Individual API keys scattered across laptops create security risks and cost overruns. + +Enterprise governance provides: +- **SSO authentication**: Corporate credentials instead of personal API keys +- **Role-based access control**: Fine-grained permissions per team and project +- **Model and tool controls**: Govern which models and tools each team accesses +- **Remote configuration**: Manage settings for all developers from one dashboard +- **Full audit logging**: Every AI interaction tracked with detailed logs + +Configure once, deploy everywhere. Developers work how they prefer while you maintain control. + +### Complete Observability + +Export logs to your existing observability stack. Track usage, costs, and performance across all teams. + +- **OpenTelemetry export**: Direct integration with Datadog, Grafana, Splunk +- **Real-time analytics**: Track adoption, performance, and patterns +- **Cost breakdown**: See exactly what each team spends on which models +- **JSON output**: Build custom dashboards in your existing tools + +The same observability standards you require for production systems. + +## Deployment + +Cline Enterprise connects securely to your infrastructure. Deploy in cloud environments, on-premises, or air-gapped networks. Configure to work with your existing security policies and compliance requirements. + +Rolling out to your organization: +1. Configure Cline Core to connect to your infrastructure +2. Set SSO, RBAC, and governance policies +3. Deploy to developers via your existing software distribution +4. Monitor usage through your observability tools + +## Next Steps + +- Review [security architecture](/enterprise-solutions/security-concerns) +- Configure [cloud provider setup](/provider-config/aws-bedrock/api-key) (AWS Bedrock, Vertex AI, Azure) +- Set up [MCP servers](/mcp/mcp-overview) for custom tooling +- Add [custom instructions](/features/cline-rules) for your codebase + +Schedule a walkthrough to see how Cline Enterprise fits your infrastructure. We'll work with your security and compliance requirements to deploy in your environment. diff --git a/docs/enterprise-solutions/security-concerns.mdx b/docs/enterprise-solutions/security-concerns.mdx index efb42b4a00d..d67ee618f1a 100644 --- a/docs/enterprise-solutions/security-concerns.mdx +++ b/docs/enterprise-solutions/security-concerns.mdx @@ -4,9 +4,7 @@ title: "Security Concerns" ## Enterprise Security with Cline -#### Cline addresses enterprise security concerns through its unique client-side architecture that prioritizes data privacy, secure cloud integration, and transparent operations. Below is a comprehensive overview of how Cline maintains robust security measures for enterprise environments. - ---- +Cline addresses enterprise security concerns through its unique client-side architecture that prioritizes data privacy, secure cloud integration, and transparent operations. Below is a comprehensive overview of how Cline maintains robust security measures for enterprise environments. ### Client-Side Architecture diff --git a/docs/exploring-clines-tools/remote-browser-support.mdx b/docs/exploring-clines-tools/remote-browser-support.mdx index becb145375b..68c3cd296c5 100644 --- a/docs/exploring-clines-tools/remote-browser-support.mdx +++ b/docs/exploring-clines-tools/remote-browser-support.mdx @@ -1,7 +1,6 @@ --- title: "Remote Browser Support" description: "Remote browser support allows Cline to utilize a remote Chrome instance, leveraging authentication tokens and session cookies relevant to certain web development test cases." -icon: globe-pointer --- The Remote Browser feature in Cline allows the AI assistant to interact with web content directly through a controlled browser instance. This enables several powerful capabilities: diff --git a/docs/features/multiroot-workspace.mdx b/docs/features/multiroot-workspace.mdx new file mode 100644 index 00000000000..50bbd548a56 --- /dev/null +++ b/docs/features/multiroot-workspace.mdx @@ -0,0 +1,164 @@ +--- +title: "Multiroot Workspace Support" +sidebarTitle: "Multiroot Workspace" +--- + +Cline's Multiroot feature works seamlessly with VSCode's multi-root workspaces, letting you manage multiple project folders in a single workspace. + + +**Important:** Multi-root workspaces are currently an experimental feature and have the following limitations: +- **Cline rules** only work in the first workspace folder +- **Checkpoints** are automatically disabled with a warning message +- Both features are restored when you return to a single-folder workspace + + +## What is multiroot workspace support? + +Instead of being limited to one project folder, Cline can read files, write code, and run commands across all folders in your VSCode workspace. This is helpful when working with monorepos, microservices, or when you're working on related projects simultaneously. + +### How it works + +When you open multiple workspace folders in VSCode, Cline automatically: +- Designates one folder as the **primary workspace** (typically the first folder added) +- Tracks all workspace folders and their paths +- Resolves file paths intelligently across workspaces +- Displays workspace information in the environment details for each API request + +## Getting Started + +### Setting Up Multi-Root Workspaces + +1. **Add folders to your workspace:** + - Use `File > Add Folder to Workspace` in VSCode + - Or create a `.code-workspace` file with multiple folder paths + - Drag and drop folders to the File Explorer + - Select multiple folders when opening a new workspace + +2. **Start using Cline** - Cline will automatically detect all your workspace folders and interact with them as needed. + +For detailed instructions on setting up multi-root workspaces in VS Code, see [Microsoft's official guide](https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces). + +### Technical behavior + +**Workspace detection** +- Cline detects all workspace folders when a task starts +- The first workspace folder becomes the primary workspace by default +- Each workspace can have its own VCS (Git, SVN, etc.) + +**Path resolution** +- Relative paths are resolved relative to the primary workspace +- You can use workspace hints to target specific workspaces: `@workspaceName:path/to/file` +- Cline attempts to intelligently determine which workspace a file belongs to + +**Command execution** +- Commands execute in the appropriate workspace context +- The working directory is set based on where files are being accessed + +## Working across workspaces + +### Referencing specific workspaces + +You can reference different workspaces naturally in your prompts: + +``` +"Read the package.json in my frontend folder and compare it with the backend dependencies" +``` + +``` +"Create a shared utility function and update both the client and server to use it" +``` + +``` +"Search for TODO comments across all my workspace folders" +``` + +### Workspace hints + +Use workspace hints to explicitly reference files in specific workspaces: + +``` +@frontend:src/App.tsx +@backend:server.ts +``` + +This syntax helps Cline resolve ambiguity when multiple workspaces contain similarly named files. + + +## Common use cases + +### Monorepo Development + +Perfect for when you have related projects in one repository: + +``` +my-app.code-workspace +├── web/ (React frontend) +├── api/ (Node.js backend) +├── mobile/ (React Native) +└── shared/ (Common utilities) +``` + +Ask Cline: *"Update the API endpoint in both web and mobile apps to match the new backend route"* + +### Microservices Architecture + +Manage multiple services from one workspace: + +``` +services.code-workspace +├── user-service/ +├── payment-service/ +├── notifications/ +└── infrastructure/ +``` + +### Full-Stack Development + +Keep everything together while maintaining separation: + +``` +fullstack.code-workspace +├── client/ (Frontend) +├── server/ (Backend API) +├── docs/ (Documentation) +└── deploy/ (Scripts & config) +``` + + +### Auto-Approve Integration + +Multiroot workspaces work with [Auto Approve](/features/auto-approve): + +- Enable permissions for operations within workspace folders +- Restrict auto-approve for files outside your workspace(s) +- Configure different levels for different workspace folders + +### Cross-Workspace Operations + +Cline can complete tasks spanning multiple workspaces: + +- **Refactoring**: Update imports and references across projects +- **Feature development**: Implement features requiring changes in multiple services +- **Documentation**: Generate docs referencing code from multiple folders +- **Testing**: Build & run tests across all workspaces and analyze results + +When working with large multiroot workspaces, start in [Plan mode](/features/plan-and-act) to let Cline understand your project structure before making changes. + +## Best Practices + +### Organizing Your Workspaces + +1. **Group related projects** that often need coordinated changes +2. **Use consistent folder structures** across workspaces when possible +3. **Name folders clearly** so Cline can understand your project structure + +### Effective Prompting & Tips + +When working with multiroot workspaces, these approaches work best: + +- **Be specific** about which workspace when it matters: *"Update the user model in the backend workspace"* +- **Reference relationships**: *"The frontend uses the API types from the shared workspace"* +- **Describe cross-workspace operations**: *"This change needs to be reflected in both the web and mobile apps"* +- **Scope your searches** when dealing with large codebases: *"Search for 'TODO' in just the frontend workspace"* +- **Break down large tasks** into workspace-specific operations when possible +- **Consider excluding large folders** like `node_modules` from your workspace search Scope diff --git a/docs/features/tasks/task-management.mdx b/docs/features/tasks/task-management.mdx new file mode 100644 index 00000000000..4826a328331 --- /dev/null +++ b/docs/features/tasks/task-management.mdx @@ -0,0 +1,105 @@ +--- +title: "Task Management" +description: "Learn how to organize, search, and manage your task history in Cline." +--- + +Cline provides tools to manage your task history, helping you organize, search, and maintain your workspace efficiently. As you accumulate tasks over time, these features become essential for productivity. + +## Accessing Task History + +Learn the different ways to open and navigate to your task history in Cline. Whether you prefer clicking buttons, using keyboard shortcuts, or the command palette, there are multiple convenient methods to access your past work. + +You can access your task history by: + +1. **Clicking the "History" button** in the Cline sidebar +2. **Using Command Palette**: Search for "Cline: Show Task History" +3. **Keyboard shortcut** (if configured in your VSCode settings) + +## Task History Interface + +Explore the main interface where all your tasks are displayed and managed. This section covers the layout, search capabilities, sorting options, and filtering tools that help you efficiently navigate through your accumulated tasks. The task history view provides a comprehensive interface for managing all your past and current tasks. + +### Search and Filter + +The history view includes search and filtering capabilities: + +#### Search Bar +- **Fuzzy search** across all task content +- Searches through prompts, responses, and code +- Instantly filters results as you type +- Highlights matching text in results + +#### Sort Options +Sort your tasks by: +- **Newest** (default) - Most recent tasks first +- **Oldest** - Earliest tasks first +- **Most Expensive** - Highest API cost tasks +- **Most Tokens** - Highest token usage +- **Most Relevant** - Best matches when searching + +#### Favorites Filter +- Toggle to show only starred tasks +- Quickly access your most important work +- Combine with search for precise filtering + +## Task Actions + +Discover the various actions you can perform on individual tasks in your history. From reopening and resuming tasks to exporting and managing them, this section explains all the available operations for task manipulation. + +Each task in the history provides several actions: + +### Primary Actions + +- **Open**: Click on a task to reopen it in the Cline chat +- **Resume**: Continue an interrupted task from where it left off +- **Export**: Save the conversation to markdown for documentation + +### Management Actions + +- **Favorite** ⭐: Click the star icon to mark important tasks +- **Delete** 🗑️: Remove individual tasks (favorites are protected) +- **Duplicate**: Create a new task based on an existing one + +## ⭐ Task Favorites + +Master the favorites system to mark and protect your most valuable tasks. This feature allows you to star important work, preventing accidental deletion while providing quick access to reference implementations and successful patterns. + +The favorites system helps you preserve and quickly access important tasks. + +### Using Favorites + +**Marking Favorites** +- Click the star icon next to any task +- Star fills in when favorited +- Click again to unfavorite + +**Protection Features** +- Favorited tasks are protected from accidental deletion +- Bulk delete operations skip favorites by default +- Can override protection with explicit confirmation + +**Use Cases for Favorites** +- Reference implementations you want to keep +- Successful problem-solving patterns +- Tasks with reusable code snippets +- Important project milestones +- Learning examples for team members + +## Task Metrics + +Gain insights into your Cline usage through task metrics. This section explains how to track token usage, API costs, and other metrics to help you optimize your workflow and manage resources effectively. + +Understanding your task metrics helps optimize usage: + +### Available Metrics + +- **Token Usage**: Total input/output tokens consumed +- **API Cost**: Estimated cost based on model pricing +- **Checkpoint Count**: Number of file snapshots created + +### Using Metrics + +- **Budget Tracking**: Monitor API costs across tasks +- **Efficiency Analysis**: Identify expensive operations +- **Model Comparison**: Compare costs between models +- **Optimization**: Find tasks that could be more efficient diff --git a/docs/features/tasks/understanding-tasks.mdx b/docs/features/tasks/understanding-tasks.mdx new file mode 100644 index 00000000000..2650486c68c --- /dev/null +++ b/docs/features/tasks/understanding-tasks.mdx @@ -0,0 +1,132 @@ +--- +title: "Understanding Tasks" +description: "Learn what tasks are in Cline, how they work, and how to create effective prompts for better results." +--- + +## What are Tasks? + +Most users interact with Cline through **tasks** - the fundamental unit of work that drives every coding session. Whether you're building a new feature, fixing a bug, refactoring code, or exploring a codebase, every interaction with Cline happens within the context of a task. A task represents a complete conversation and work session between you and the AI agent, created through **prompts** - the instructions you provide to tell Cline what you want to accomplish. Tasks serve as self-contained work sessions that capture your entire conversation with Cline, including all the code changes, command executions, and decisions made along the way. + +This approach ensures that your work is organized, traceable, and resumable. Each task maintains its own isolated context, allowing you to work on multiple projects simultaneously without confusion. The beauty of Cline's task system lies in its flexibility and persistence, providing a collaborative coding session where you provide the direction through prompts, and Cline executes your vision with precision. + +### Key Characteristics + +Each task in Cline: + +- **Has a unique identifier**: Every task gets its own ID and dedicated storage directory +- **Contains the full conversation**: All messages, tool uses, and results are preserved +- **Tracks resources used**: Token usage, API costs, and execution time are monitored +- **Can be interrupted and resumed**: Tasks maintain their state across VSCode sessions +- **Creates checkpoints**: File changes are tracked through Git-based snapshots +- **Enables documentation**: Tasks can be exported as markdown for team documentation +- **Provides cost management**: Resource tracking helps monitor API usage and costs + +These features make Cline not just a coding tool, but a comprehensive development agent that understands the full lifecycle of your work. + +## Creating Tasks with Prompts + +Tasks begin with prompts - your instructions to Cline. The quality of your results depends heavily on how you describe what you want. + +### Prompt Components + +A well-structured prompt typically includes: + +- **Goal**: What you want to accomplish +- **Context**: Background information and constraints +- **Requirements**: Specific features or functionality needed +- **Preferences**: Technology choices, coding style, etc. +- **Examples**: References to guide the implementation + + +**Want to master the art of prompting?** + +Deep dive into **Module 1: "Prompting"** in [Cline Learn](https://clinelearn.com) to become an expert at creating effective prompts. The module covers: +- Structured prompting techniques +- Context optimization strategies +- Common prompting patterns +- Advanced prompt engineering +- Real-world examples and exercises + +Good prompting skills lead to faster task completion, more accurate results, fewer iterations needed, and better code quality. + + +## Task Execution Modes + +Cline operates in two distinct modes that help structure your workflow: + +- **Plan Mode**: For information gathering, discussing approaches, and creating strategies without making changes +- **Act Mode**: For actual implementation where Cline executes file modifications, runs commands, and uses tools + +→ **[Learn more about Plan and Act modes](/features/plan-and-act)** to understand when and how to use each mode effectively. + +## Task Resources + +Each task consumes resources that are tracked: + +- **Tokens**: The amount of text processed (input and output) +- **API Costs**: Monetary cost based on the model and token usage +- **Time**: Duration from start to completion +- **Checkpoints**: Number of file state snapshots created + +## Common Task Patterns + +### Code Generation +``` +Create a TypeScript function that validates email addresses using regex. +Include unit tests using Jest and handle edge cases like international domains. +``` + +### Bug Fixing +``` +@terminal The app crashes when clicking the submit button. +Fix the error and ensure proper error handling is in place. +``` + +### Refactoring +``` +Refactor the authentication logic in @auth.ts to use async/await +instead of callbacks. Maintain all existing functionality. +``` + +### Feature Implementation +``` +Add a dark mode toggle to the settings page. Use the existing theme +context and persist the preference to localStorage. +``` + +## Task Resumption + +One of Cline's powerful features is the ability to resume interrupted tasks: + +### When Tasks Get Interrupted + +- You stop a long-running task +- An error occurs that needs intervention +- You need to switch to another task + +### Resuming a Task + +1. Open the task from history +2. Cline loads the complete conversation +3. File states are checked against checkpoints +4. The task continues with awareness of the interruption +5. You can provide additional context if needed + +## Understanding Task Context + +Tasks maintain context throughout their lifecycle: + +- **Conversation History**: All previous messages and responses +- **File Changes**: Tracked modifications and their order +- **Tool Results**: Output from commands and operations +- **Checkpoint States**: Snapshots of file states at key points + +This context allows Cline to: +- Understand what has been done +- Maintain consistency in approach +- Resume work intelligently +- Learn from previous attempts + +→ **[Learn more about Context Management](/getting-started/understanding-context-management)** to understand how Cline manages and optimizes context across tasks. + +Understanding how tasks work is fundamental to using Cline effectively. With well-crafted prompts and an understanding of the task lifecycle, you can leverage Cline's full potential to accelerate your development workflow. diff --git a/docs/getting-started/for-new-coders.mdx b/docs/getting-started/for-new-coders.mdx deleted file mode 100644 index 601be2760e1..00000000000 --- a/docs/getting-started/for-new-coders.mdx +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "For New Coders" -description: "Welcome to Cline, your AI-powered coding companion! This guide will help you quickly set up your development environment and begin your coding journey with ease." ---- - -> **Tip:** If you're completely new to coding, take your time with each step. There's no rush — Cline is here to guide you! - -### Getting Started - -Before you jump into coding, make sure you have these essentials ready: - -#### 1. **VS Code** - -A popular, free, and powerful code editor. - -- [Download VS Code](https://code.visualstudio.com/) - -**Recommended YouTube Tutorial:** [How to Install VS Code](https://www.youtube.com/watch?v=MlIzFUI1QGA) - -> **Pro Tip:** Install VS Code in your Applications folder (macOS) or Program Files (Windows) for easy access from your dock or start menu. - -#### 2. **Organize Your Projects** - -Create a dedicated folder named `Cline` in your Documents folder for all your coding projects: - -- **macOS:** `/Users/[your-username]/Documents/Cline` -- **Windows:** `C:\Users\[your-username]\Documents\Cline` - -Inside your `Cline` folder, structure projects clearly: - -- `Documents/Cline/workout-app` _(e.g., for a fitness tracking app)_ -- `Documents/Cline/portfolio-website` _(e.g., to showcase your work)_ - -> **Tip:** Keeping your projects organized from the start will save you time and confusion later! - -#### 3. **Install the Cline VS Code Extension** - -Enhance your coding workflow by installing the Cline extension directly within VS Code: - -- Get Started with Cline Extension Tutorial - -**Recommended YouTube Tutorial:** [How To Install Extensions in VS Code](https://www.youtube.com/watch?v=E7trgwZa-mk) - -> **Pro Tip:** After installing, reload VS Code to ensure the extension is activated properly. - -#### 4. **Essential Development Tools** - -Basic software required for coding efficiently: - -- Homebrew (macOS) -- Node.js -- Git - -[Follow our detailed guide on Installing Essential Development Tools with step-by-step help from Cline.](https://docs.cline.bot/getting-started/installing-dev-essentials#installing-dev-essentials) - -**Recommended YouTube Tutorials for Manual Installation:** - -- **For macOS:** - - [Install Homebrew on Mac](https://www.youtube.com/watch?v=hwGNgVbqasc) - - [Install Git on macOS 2024](https://www.youtube.com/watch?v=B4qsvQ5IqWk) - - [Install Node.js on Mac (M1 | M2 | M3)](https://www.youtube.com/watch?v=I8H4wolRFBk) -- **For Windows:** - - [Install Git on Windows 10/11 (2024)](https://www.youtube.com/watch?v=yjxv1HuRQy0) - - [Install Node.js in Windows 10/11](https://www.youtube.com/watch?v=uCgAuOYpJd0) - -> **Note:** If you run into permission issues during installation, try running your terminal or command prompt as an administrator. - -You're all set! Dive in and start coding smarter and faster with **Cline**. diff --git a/docs/getting-started/installing-cline.mdx b/docs/getting-started/installing-cline.mdx index 44d1f7f1aa8..55fb2053a1f 100644 --- a/docs/getting-started/installing-cline.mdx +++ b/docs/getting-started/installing-cline.mdx @@ -1,54 +1,89 @@ --- title: "Installing Cline" -description: "Get Cline set up in your editor and start building projects with AI assistance." +description: "Get Cline up and running in your favorite IDE with these simple installation steps" --- -## Prerequisites + +**Ready to get started?** Installation takes less than 2 minutes! Choose your editor below and follow the simple steps. + -Before installing Cline, make sure you have the following: +## Before You Begin -### Create a Cline Account - -Create a Cline account for the best experience. Creating a Cline account is completely free and you can [sign up here](https://app.cline.bot/signup). A Cline account provides: -- Access to multiple AI models including stealth models -- Seamless setup without needing to manage API keys -- At times, we partner with model providers to offer inferencing at no cost through your Cline account - -### Compatible Editor - -Cline works with the following IDEs: -- **VS Code** - Microsoft's popular code editor -- **Cursor** - AI-powered code editor based on VS Code -- **JetBrains IDEs** - IntelliJ IDEA, PyCharm, WebStorm, DataSpell, PhpStorm, and other JetBrains products -- **VSCodium** - Open-source version of VS Code -- **Windsurf** - VS Code-compatible editor - -Make sure you have one of these editors installed before proceeding with the Cline installation. - -## Choose Your Editor - -Cline works across multiple IDEs. Select your preferred editor below for installation instructions: + + + Sign up for a **free Cline account** to get: + - Access to multiple AI models including stealth models + - Seamless setup without managing API keys + - Occasional free inferencing through partner providers + + + + Cline works with: + - **VS Code** / **Cursor** + - **JetBrains IDEs** (IntelliJ, PyCharm, WebStorm, etc.) + - **VSCodium** / **Windsurf** + + Install one before proceeding. + + +## Installation Instructions - ### Installation Steps - - 1. **Open VS Code** and navigate to the Extensions view (`Ctrl/Cmd + Shift + X`) - 2. **Search for "Cline"** in the Extensions marketplace - 3. **Click Install** on the Cline extension - - - VS Code marketplace showing Cline extension + + VS Code logo - - 4. **Access Cline** after installation: - - Click the Cline icon in the Activity Bar, or - - Use Command Palette (`Ctrl/Cmd + Shift + P`) → "Cline: Open In New Tab" - - > **Note:** If VS Code shows "Running extensions might..." dialog, click "Allow". If you don't see the Cline icon, restart VS Code. + + + Launch VS Code and open the Extensions view: + - Press `Ctrl/Cmd + Shift + X`, or + - Click the Extensions icon in the Activity Bar + + + + Type **"Cline"** in the Extensions marketplace search bar + + VS Code marketplace showing Cline extension + + + + + Click the **Install** button on the Cline extension + + + VS Code marketplace showing Cline extension + + + + + After installation completes: + - Click the **Cline icon** in the Activity Bar, or + - Open Command Palette (`Ctrl/Cmd + Shift + P`) → type **"Cline: Open In New Tab"** + + + Cline opened in VSCode + + + + If VS Code shows "Running extensions might..." dialog, click **Allow**. If you don't see the Cline icon, restart VS Code. + + + + + + **Installation Complete!** You should now see the Cline interface in your editor. Time to sign in! + @@ -96,57 +131,113 @@ Cline works across multiple IDEs. Select your preferred editor below for install style={{ width: "200px", height: "auto", margin: "0 auto 20px auto", display: "block" }} /> - Cline for JetBrains works almost identically to Cline in VSCode. All the core features work properly: diff editing, using tools, logging in with different providers, MCP servers, Cline rules and workflows, and more. - - - ### Installation Steps - - **Method 1: From IDE (Recommended)** - 1. Open your JetBrains IDE - 2. Go to **Settings** (`Ctrl+Alt+S` on Windows/Linux, `Cmd+,` on macOS) - 3. Navigate to **Plugins** → **Marketplace** - 4. Search for "Cline" and click **Install** - 5. Restart your IDE - - - JetBrains marketplace showing Cline plugin search results - - - **Method 2: Browser Install** - - Visit the [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/28247-cline) and click **Install to IDE**. - - - - 1. Download the plugin from the [marketplace page](https://plugins.jetbrains.com/plugin/28247-cline) - 2. Go to **Settings** → **Plugins** - 3. Click the gear icon → **Install Plugin from Disk** - 4. Select the downloaded `.zip` file - 5. Restart your IDE - - - - ### Using the Plugin - - After installation, you’ll find Cline in your IDE. Look for the Cline tool window (usually on the right side) or go to View → Tool Windows → Cline. - - ### Key Features - - Cline for JetBrains includes all core features: - - Diff editing and file modifications - - Multiple API providers (Anthropic, OpenAI, local models) - - MCP servers and custom tools - - Cline rules and workflows - - @ mentions for files, folders, and problems - - Drag & drop support - - > **Note:** Terminal output appears in collapsible sections rather than streaming directly to chat. - - ### Key Differences from VSCode - The terminal integration works differently in JetBrains. Unlike VSCode where terminal output streams directly to the chat, JetBrains shows command output in a collapsible section. Commands still execute successfully - you just need to expand the Command Output section to see results. + + Cline for JetBrains works almost identically to VS Code, with all core features: diff editing, tools, multiple API providers, MCP servers, Cline rules/workflows, and more. + + + ### Choose Your Installation Method + + + + + + In your JetBrains IDE, go to **Settings**: + - Windows/Linux: `Ctrl+Alt+S` + - macOS: `Cmd+,` + + + + Go to **Plugins** → **Marketplace** tab + + + + Search for **"Cline"** and click **Install** + + + JetBrains marketplace showing Cline plugin search results + + + + + Restart your IDE to complete the installation + + JetBrains marketplace showing Cline plugin search results + + + + + + + + + Go to the [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/28247-cline) + + + + Click the **Install to IDE** button + + + + Your IDE will open and prompt you to confirm the installation + + + + Restart to complete the installation + + + + + + + + Download from the [marketplace page](https://plugins.jetbrains.com/plugin/28247-cline) + + + + Go to **Settings** → **Plugins** + + + + Click the gear icon → **Install Plugin from Disk** + + + + Select the downloaded `.zip` file and restart your IDE + + + + + + + **Installation Complete!** Find Cline in **View** → **Tool Windows** → **Cline** (usually on the right side). + + + ### What Works in JetBrains + + + + - Diff editing and file modifications + - Multiple API providers (Anthropic, OpenAI, local models) + - MCP servers and custom tools + - Cline rules and workflows + - @ mentions for files, folders, and problems + - Drag & drop support + + + + **JetBrains shows terminal output differently than VS Code:** + - VS Code: Output streams directly to chat + - JetBrains: Output appears in collapsible "Command Output" sections + + Commands execute successfully in both—just expand the section to see results in JetBrains. + + @@ -188,17 +279,32 @@ Cline works across multiple IDEs. Select your preferred editor below for install - ### Installation Steps + + These editors use the **Open VSX Registry** instead of the VS Code Marketplace, but the installation process is nearly identical. + + + + + Launch your editor (VSCodium, Windsurf, etc.) and open Extensions view: + - Press `Ctrl/Cmd + Shift + X` + - For VS Code-compatible editors using Open VSX Registry: + + Type **"Cline"** in the marketplace search bar + - 1. **Open your editor** (VSCodium, Windsurf, etc.) - 2. **Navigate to Extensions view** (`Ctrl/Cmd + Shift + X`) - 3. **Search for "Cline"** in the marketplace - 4. **Select "Cline" by saoudrizwan** and click **Install** - 5. **Reload** if prompted + + Select **"Cline" by saoudrizwan** and click **Install** + - > **Note:** These editors use the Open VSX Registry instead of the VS Code Marketplace. + + Reload your editor if prompted to complete installation + + + + + **Installation Complete!** Look for the Cline icon in your Activity Bar or use the Command Palette. + @@ -240,33 +346,64 @@ Cline works across multiple IDEs. Select your preferred editor below for install -### Sign In to Your Cline Account - -Now that you have Cline installed, sign in to access your account: +## Next Steps: Sign In & Start Building -1. **Open Cline** in your editor (click the Cline icon in the Activity Bar or Tool Windows) -2. **Click "Sign In"** - you'll see this button in the Cline interface -3. **Complete authentication** - you'll be redirected to [app.cline.bot](https://app.cline.bot) to sign in -4. **Return to your editor** - once signed in, you'll be automatically redirected back + + + Find and open Cline in your editor: + - **VS Code/Cursor/VSCodium/Windsurf:** Click the Cline icon in the Activity Bar + - **JetBrains:** Go to **View** → **Tool Windows** → **Cline** + + + Click the **Sign Up** button in the Cline interface + + + You'll be redirected to [app.cline.bot](https://app.cline.bot) to authenticate. After signing in, you'll automatically return to your editor. + + + Cline sign up screen + + + + + + **Congratulations!** You're all set to start using Cline! + + Cline is now ready to help you build projects. + + + -### Your First Interaction with Cline - -You're ready to start building! Copy and paste this prompt into the Cline chat window: - -``` -Hey Cline! Could you help me create a new project folder called "hello-world" in my Cline directory and make a simple webpage that says "Hello World" in big blue text? -``` - -> **Pro Tip:** Cline will help you create the project folder and set up your first webpage! - -### Tips for Working with Cline +## Tips for Success -- **Ask Questions:** If you're unsure about something, ask Cline! -- **Use Screenshots:** Cline can understand images — show him what you're working on. -- **Copy and Paste Errors:** Share error messages in the chat for solutions. -- **Speak Plainly:** Use your own words — Cline will translate them into code. + + + Don't know something? Ask in Plan Mode! Cline can explain concepts, debug errors, and guide you through tasks. + + + + Some models understand screenshots of what you're working on or errors you encounter. + + + + Use @problems to share error messages for quick solutions and debugging help. + + + + Use your own words—no need for technical jargon. Cline will translate your ideas into code. + + -### Still Struggling? +## Need Help? -Join our [Discord community](https://discord.gg/cline) and engage with our team and other Cline users directly. + + + Connect with our team and community for support, tips, and discussions. + + + + Explore guides for new coders, model selection, and advanced features. + + diff --git a/docs/getting-started/installing-dev-essentials.mdx b/docs/getting-started/installing-dev-essentials.mdx deleted file mode 100644 index d269fb0c925..00000000000 --- a/docs/getting-started/installing-dev-essentials.mdx +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: "Installing Dev Essentials" -description: >- - When you start coding, you'll need some essential development tools installed - on your computer. Cline can help you install everything you need in a safe, - guided way. ---- - -### The Essential Tools - -Here are the core tools you'll need for development: - -- **Node.js & npm:** Required for JavaScript and web development -- **Git:** For tracking changes in your code and collaborating with others -- **Package Managers:** Tools that make it easy to install other development tools - - Homebrew for macOS - - Chocolatey for Windows - - apt/yum for Linux - -> **Tip:** These tools are the foundation of your developer toolkit. Installing them properly will set you up for success! - -### Let Cline Install Everything - -Copy one of these prompts based on your operating system and paste it into **Cline**: - -#### For macOS - -``` -Hello Cline! I need help setting up my Mac for software development. Could you please help me install the essential development tools like Homebrew, Node.js, Git, and any other core utilities that are commonly needed for coding? I'd like you to guide me through the process step-by-step. -``` - -#### For Windows - -``` -Hello Cline! I need help setting up my Windows PC for software development. Could you please help me install the essential development tools like Node.js, Git, and any other core utilities that are commonly needed for coding? I'd like you to guide me through the process step-by-step. -``` - -#### For Linux - -``` -Hello Cline! I need help setting up my Linux system for software development. Could you please help me install the essential development tools like Node.js, Git, and any other core utilities that are commonly needed for coding? I'd like you to guide me through the process step-by-step. -``` - -> **Pro Tip:** Cline will show you each command before running it. You stay in control the entire time! - -### What Will Happen - -Cline will guide you through the following steps: - -1. Installing the appropriate package manager for your system -2. Using the package manager to install Node.js and Git -3. Showing you the exact command before it runs (you approve each step!) -4. Verifying each installation is successful - -> **Note:** You might need to enter your computer's password for some installations. This is normal! - -### Why These Tools Are Important - -- **Node.js & npm:** - - Build websites with frameworks like React or Next.js - - Run JavaScript code - - Install JavaScript packages -- **Git:** - - Save different versions of your code - - Collaborate with other developers - - Back up your work -- **Package Managers:** - - Quickly install and update development tools - - Keep your environment organized and up to date - -### Notes - -> **Tip:** The installation process is interactive — Cline will guide you step by step! - -- All commands are shown to you for approval before they run. -- If you run into any issues, Cline will help troubleshoot them. -- You may need to enter your computer's password for certain steps. - -### Additional Tips for New Coders - -#### Understanding the Terminal - -The Terminal is an application where you can type commands to interact with your computer. - -- **macOS:** Open it by searching for "Terminal" in Spotlight. -- **Example:** - -``` -$ open -a Terminal -``` - -#### Understanding VS Code Features - -- **Terminal in VS Code:** Run commands directly from within VS Code! - - Go to **View > Terminal** or press \`Ctrl + \`\`. - - Example: - -``` -$ node -v -v16.14.0 -``` - -- **Document View:** Where you edit your code files. - - Open files from the Explorer panel on the left. -- **Problems Section:** View errors or warnings in your code. - - Access it by clicking the lightbulb icon or **View > Problems**. - -#### Common Features - -- **Command Line Interface (CLI):** A powerful tool for running commands. -- **Permissions:** You might need to grant permissions to certain commands — this keeps your system secure. diff --git a/docs/getting-started/model-selection-guide.mdx b/docs/getting-started/model-selection-guide.mdx deleted file mode 100644 index 4760797a063..00000000000 --- a/docs/getting-started/model-selection-guide.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: "Model Selection Guide" -description: "Last updated: August 20, 2025." ---- - -New models drop constantly, so this guide focuses on what's working well with Cline right now. We'll keep it updated as the landscape shifts. - -## Current Top Models - -| Model | Context Window | Input Price* | Output Price* | Best For | -|-------|---------------|--------------|---------------|----------| -| **Claude Sonnet 4.5** | 1M tokens | $3-6 | $15-22.50 | Reliable tool usage, complex codebases | -| **Qwen3 Coder** | 256K tokens | $0.20 | $0.80 | Coding tasks, open source flexibility | -| **Gemini 2.5 Pro** | 1M+ tokens | TBD | TBD | Large codebases, document analysis | -| **GPT-5** | 400K tokens | $1.25 | $10 | Latest OpenAI tech, three modes | - -*Per million tokens - -## Budget Options - -| Model | Context Window | Input Price* | Output Price* | Notes | -|-------|---------------|--------------|---------------|-------| -| **DeepSeek V3** | 128K tokens | $0.14 | $0.28 | Great value for daily coding | -| **DeepSeek R1** | 128K tokens | $0.55 | $2.19 | Budget reasoning champion | -| **Qwen3 32B** | 128K tokens | Varies | Varies | Open source, multiple providers | -| **Z AI GLM 4.5** | 128K tokens | TBD | TBD | MIT licensed, hybrid reasoning | - -*Per million tokens - - -## Context Window Guide - -| Size | Word Count | Use Case | -|------|------------|----------| -| 32K tokens | ~24,000 words | Single files, small projects | -| 128K tokens | ~96,000 words | Most coding projects | -| 200K tokens | ~150,000 words | Large codebases | -| 400K+ tokens | ~300,000+ words | Entire applications | - -**Performance note**: Most models start dropping in quality around 400-500K tokens, even if they claim higher limits. - -## Open Source vs Closed Source - -### Open Source Advantages -- **Multiple providers** compete to host them -- **Cheaper pricing** due to competition -- **Provider choice** - switch if one goes down -- **Faster innovation** cycles - -### Open Source Models Available -- **Qwen3 Coder** (Apache 2.0) -- **Z AI GLM 4.5** (MIT) -- **Kimi K2** (Open source) -- **DeepSeek series** (Various licenses) - -## Quick Decision Matrix - -| If you want... | Use this | -|----------------|----------| -| Something that just works | Claude Sonnet 4.5 | -| To save money | DeepSeek V3 or Qwen3 variants | -| Huge context windows | Gemini 2.5 Pro or Claude Sonnet 4.5 | -| Open source | Qwen3 Coder, Z AI GLM 4.5, or Kimi K2 | -| Latest tech | GPT-5 | -| Speed | Qwen3 Coder on Cerebras (fastest available) | - -## What Others Are Using - -Check [OpenRouter's Cline usage stats](https://openrouter.ai/apps?url=https%3A%2F%2Fcline.bot%2F) to see real usage patterns from the community. - -## Context Management - -Cline automatically handles context limits with [auto-compact](/features/auto-compact). When you approach your model's limit, Cline summarizes the conversation to keep working. You don't need to micromanage this. - -## The Bottom Line - -Start with **Claude Sonnet 4.5** if you want reliability. Experiment with **open source options** once you're comfortable to find the best fit for your workflow and budget. - -The landscape moves fast - these recommendations reflect what's working now, but keep an eye on new releases. diff --git a/docs/getting-started/selecting-your-model.mdx b/docs/getting-started/selecting-your-model.mdx new file mode 100644 index 00000000000..579f6857fa3 --- /dev/null +++ b/docs/getting-started/selecting-your-model.mdx @@ -0,0 +1,64 @@ +--- +title: "Selecting Your Model" +description: "Get started with your first AI model in Cline" +--- + +Cline needs an AI model to understand your requests and write code. Think of it like choosing which expert to work with - different models have different strengths and costs. + +## Quick Start: Choose Your Provider + +The easiest way to get started is with **Cline** as your provider: + +1. **Open Cline Settings**: Click the gear icon (⚙️) in the top-right corner of Cline's chat +2. **Select "Cline"** from the API Provider dropdown +3. **Choose a model** from the dropdown - we recommend starting with **Claude Sonnet 4.5** or **DeepSeek V3** + + + Select Cline Provider + + +**That's it!** No API keys to manage, and you'll get access to multiple models. + + +**Free models available**: Cline occasionally offers free inferencing through partner providers. When available, you'll see these options in your model dropdown. + + +## Alternative: Use Another Provider + +If you prefer to use your own API keys, you can select from providers like: + +- **OpenRouter** - Great value, multiple models +- **Anthropic** - Direct access to Claude models +- **OpenAI** - Access to GPT models +- **Google Gemini** - Google's AI models +- **Ollama** - Run models locally on your computer + +After selecting a provider, you'll need to: +1. Get an API key from their website +2. Paste it into the API Key field in Cline settings +3. Choose your model + + +Most providers require payment information before generating API keys. + + +## Which Model Should I Choose? + +If you're just getting started, we recommend: + +| Your Priority | Choose This Model | Why | +|---------------|-------------------|-----| +| **Reliability** | Claude Sonnet 4.5 | Most reliable for coding tasks | +| **Value** | DeepSeek V3 | Great performance at low cost | +| **Speed** | Qwen3 Coder | Fast responses | +| **Privacy** | Any Ollama model | Runs on your computer | + +You can switch models anytime without losing your conversation. + +## Next Steps + +With your model configured, you're all set! In the next section, we'll walk you through completing your first task with Cline and show you how to interact with the AI to write, debug, and refactor code. + + + Want to understand model pricing, context windows, and advanced selection strategies? Check out our comprehensive Model Selection Guide. + diff --git a/docs/getting-started/task-management.mdx b/docs/getting-started/task-management.mdx deleted file mode 100644 index 3fd8c6918a8..00000000000 --- a/docs/getting-started/task-management.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Task Management in Cline" -description: "Learn how to effectively manage your task history, use favorites, and organize your work in Cline." ---- - -# Task Management - -As you use Cline, you'll accumulate many tasks over time. The task management system helps you organize, filter, search, and clean up your task history to keep your workspace efficient. - -## Accessing Task History - -You can access your task history by: - -1. Clicking on the "History" button in the Cline sidebar -2. Using the command palette to search for "Cline: Show Task History" - -## Task History Features - -The task history view provides several powerful features: - -### Searching and Filtering - -- **Search Bar**: Use the fuzzy search at the top to quickly find tasks by content -- **Sort Options**: Sort tasks by: - - Newest (default) - - Oldest - - Most Expensive (highest API cost) - - Most Tokens (highest token usage) - - Most Relevant (when searching) -- **Favorites Filter**: Toggle to show only favorited tasks - -### Task Actions - -Each task in the history view has several actions available: - -- **Open**: Click on a task to reopen it in the Cline chat -- **Favorite**: Click the star icon to mark a task as a favorite -- **Delete**: Remove individual tasks (favorites are protected from deletion) -- **Export**: Export a task's conversation to markdown - -## ⭐ Task Favorites - -The favorites feature allows you to mark important tasks that you want to preserve and find quickly. - -### How Favorites Work - -- **Marking Favorites**: Click the star icon next to any task to toggle its favorite status -- **Protection**: Favorited tasks are protected from individual and bulk deletion operations (can be overridden) -- **Filtering**: Use the favorites filter to quickly access your important tasks - -## Batch Operations - -The task history view supports several batch operations: - -- **Select Multiple**: Use the checkboxes to select multiple tasks -- **Select All/None**: Quickly select or deselect all tasks -- **Delete Selected**: Remove all selected tasks -- **Delete All**: Remove all tasks from history (favorites are preserved unless you choose to include them) - -## Best Practices - -1. **Favorite Important Tasks**: Mark reference tasks or frequently accessed conversations as favorites -2. **Regular Cleanup**: Periodically remove old or unused tasks to improve performance -3. **Use Search**: Leverage the fuzzy search to quickly find specific conversations -4. **Export Valuable Tasks**: Export important tasks to markdown for external reference - -Task management helps you maintain an organized workflow when using Cline, allowing you to quickly find past conversations, preserve important work, and keep your history clean and efficient. diff --git a/docs/getting-started/understanding-context-management.mdx b/docs/getting-started/understanding-context-management.mdx deleted file mode 100644 index baf170685e8..00000000000 --- a/docs/getting-started/understanding-context-management.mdx +++ /dev/null @@ -1,196 +0,0 @@ ---- -title: "Context Management" -description: "Context is key to getting the most out of Cline" ---- - -> **Quick Reference** -> -> - Context = The information Cline knows about your project -> - Context Window = How much information Cline can hold at once -> - Use context files to maintain project knowledge -> - Reset when the context window gets full - -## Understanding Context & Context Windows - - - In a world of infinite context, the context window is what Cline currently has available - - -Think of working with Cline like collaborating with a thorough, proactive teammate: - -### How Context is Built - -Cline actively builds context in two ways: - -1. **Automatic Context Gathering (i.e. Cline-driven)** - - Proactively reads related files - - Explores project structure - - Analyzes patterns and relationships - - Maps dependencies and imports - - Asks clarifying questions -2. **User-Guided Context** - - Share specific files - - Provide documentation - - Answer Cline's questions - - Guide focus areas - - Share design thoughts and requirements - -**Key Point**: Cline isn't passive - it actively seeks to understand your project. You can either let it explore or guide its focus, especially in [Plan Mode](/features/plan-and-act). - -### Context & Context Windows - -Think of context like a whiteboard you and Cline share: - -- **Context** is all the information available: - - What Cline has discovered - - What you've shared - - Your conversation history - - Project requirements - - Previous decisions -- **Context Window** is the size of the whiteboard itself: - - Measured in tokens (1 token ≈ 3/4 of an English word) - - Each model has a fixed size: - - Claude Sonnet 4.5: 1,000,000 tokens - - Qwen3 Coder: 256,000 tokens - - Gemini 2.5 Pro: 1,000,000+ tokens - - GPT-5: 400,000 tokens - - When the whiteboard is full, Cline automatically summarizes the conversation to free up space - -**Important**: Having a large context window doesn't mean you should fill it completely. Models start degrading around 400-500K tokens even if they claim higher limits. Just like a cluttered whiteboard, too much information can make it harder to focus on what's important. - -## Understanding the Context Window Progress Bar - -Cline provides a visual way to monitor your context window usage through a progress bar: - - - Context window progress bar - - -### Reading the Bar - -- ↑ shows input tokens (what you've sent to the LLM) -- ↓ shows output tokens (what the LLM has generated) -- The progress bar visualizes how much of your context window you've used -- The total shows your model's maximum capacity (e.g., 1M for Claude Sonnet 4.5) - -### When to Watch the Bar - -- During long coding sessions -- When working with multiple files -- Before starting complex tasks -- When Cline seems to lose context - -**Tip**: With [Auto Compact](/features/auto-compact), Cline can now handle long conversations automatically. When combined with [Focus Chain](/features/focus-chain), you can work on complex projects that span multiple context windows without losing progress. - -## Automatic Context Management - -Cline includes intelligent features to manage context automatically: - -### Default Settings You Should Keep On - -**Focus Chain** - Enabled by default in v3.25. Cline generates a todo list at task start and keeps it in context so the thread doesn't drift. You can edit the markdown to add or reorder steps and Cline will adapt. [Learn more about Focus Chain](/features/focus-chain). - -**Auto Compact** - Always on. As the context window reaches its limit, Cline creates a comprehensive summary, replaces the bloated history, and continues where it left off. Decisions, code changes, and state are preserved. [Learn more about Auto Compact](/features/auto-compact). - -## Advanced Context Tools - -When you need more control over context management: - -### Deep Planning (`/deep-planning`) -For substantial features, refactors, or integrations. Cline investigates your codebase, asks targeted questions, then writes `implementation_plan.md`. It creates a fresh task with distilled, high-value context. [Learn more about Deep Planning](/features/slash-commands/deep-planning). - -### New Task (`/newtask`) -At natural transition points, packages only what matters into a fresh task. Clean slate for implementation after research, or crisp handoff between teammates. [Learn more about New Task](/features/slash-commands/new-task). - -### Smol (`/smol`) -Compress the conversation in place to keep momentum. Ideal during debugging or exploratory work when you don't want to break flow. [Learn more about Smol](/features/slash-commands/smol). - -### Memory Bank + .clinerules -For non-trivial projects. The Memory Bank captures project knowledge as Markdown in your repo. `.clinerules` are version-controlled instructions that align Cline's behavior with your team. [Learn more about Memory Bank](/prompting/cline-memory-bank) and [Cline Rules](/features/cline-rules). - -## Working with Context Files - -Context files help maintain understanding across sessions. They serve as documentation specifically designed to help AI assistants understand your project. - -#### Approaches to Context Files - -1. **Evergreen Project Context (Memory Bank)** - - Living documentation that evolves with your project - - Updated as architecture and patterns emerge - - Example: The Memory Bank pattern maintains files like `techContext.md` and `systemPatterns.md` - - Useful for long-running projects and teams -2. **Task-Specific Context** - - - Created for specific implementation tasks - - Document requirements, constraints, and decisions - - Example: - - ```markdown - # auth-system-implementation.md - - ## Requirements - - - OAuth2 implementation - - Support for Google and GitHub - - Rate limiting on auth endpoints - - ## Technical Decisions - - - Using Passport.js for provider integration - - JWT for session management - - Redis for rate limiting - ``` - -3. **Knowledge Transfer Docs** - - Switch to plan mode and ask Cline to document everything you've accomplished so far, along with the remaining steps, in a markdown file. - - Copy the contents of the markdown file. - - Start a new task using that content as context. - -#### Using Context Files Effectively - -1. **Structure and Format** - - Use clear, consistent organization - - Include relevant examples - - Link related concepts - - Keep information focused -2. **Maintenance** - - Update after significant changes - - Version control your context files - - Remove outdated information - - Document key decisions - -## Practical Tips - -1. **Starting New Projects** - - Let Cline explore the codebase - - Answer its questions about structure and patterns - - Consider setting up basic context files - - Document key design decisions -2. **Ongoing Development** - - Update context files with significant changes - - Share relevant documentation - - Use Plan mode for complex discussions - - Start fresh sessions when needed -3. **Team Projects** - - Share common context files (consider using [.clinerules](/features/cline-rules) files in project roots) - - Document architectural decisions - - Maintain consistent patterns - - Keep documentation current - -## Bonus Context Tips - -- You can @ links and have the webpage's context added to Cline (docs, blogs, etc.) -- Utilize MCP servers to pull in context from your external knowledge bases -- Screenshots can be used as context for models that support image inputs - -## The Bottom Line - -Cline already does a lot of context work for you - [Focus Chain](/features/focus-chain), [Auto Compact](/features/auto-compact), and the planning flow are designed to keep the thread intact across long horizons. The goal is to help Cline maintain consistent understanding of your project across sessions. - -Remember: The goal is to keep only what matters in view, at every step. diff --git a/docs/getting-started/what-is-cline.mdx b/docs/getting-started/what-is-cline.mdx deleted file mode 100644 index 08fa2b16892..00000000000 --- a/docs/getting-started/what-is-cline.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: "What is Cline?" -description: "An introduction to Cline, your AI-powered development assistant for modern IDEs." ---- - -Cline is an open source AI coding agent that brings frontier AI models directly to your IDE. Unlike autocomplete tools, Cline is a true coding agent that can understand entire codebases, plan complex changes, and execute multi-step tasks. - -## Open Source AI Coding, Uncompromised - -Cline gives you direct, transparent access to frontier AI with no limits, no surprises, and no model ecosystem lock-in. See every decision. Choose any model. Control your costs. - -### Complete Transparency - -Watch in real-time as Cline reads files, considers approaches, and proposes changes. Every decision is visible, every edit reviewable before it's made. This isn't just "explainable AI" - it's complete transparency. - -### Your Models, Your Control - -Use Claude for complex reasoning, Gemini for massive contexts, or Qwen3 Coder for efficiency. Switch instantly as new models launch. Your API keys, your choice. No gatekeeping innovation. - -### Built for Real Engineering - -Cline can: -- **Read and write files** across your entire codebase -- **Execute terminal commands** and debug errors -- **Plan complex features** before writing code -- **Connect to external systems** through MCP servers -- **Understand large codebases** with intelligent context management - -## Plan & Act Mode - -Cline explores your codebase and works with you to create comprehensive plans before writing a single line of code, ensuring it understands the full context of your project. - -**Plan Mode** for complex tasks - Cline explores, asks questions, and creates detailed implementation plans. - -**Act Mode** for execution - Cline implements the plan with full transparency and control. - -## Zero Trust by Design - -Your code never touches our servers. Cline runs entirely client-side with your API keys, making it the only option for enterprises with strict security requirements. - -**Open source** means your security team can review every line. See exactly how Cline works, what it sends to AI providers, and how decisions are made. - -## Key Features - -### Focus Chain -Automatic todo list management with real-time progress tracking throughout your tasks. Keeps Cline on track across long projects. - -### Auto Compact -When conversations get long, Cline automatically summarizes to preserve context while freeing up space to continue working. - -### Deep Planning -For complex features, Cline investigates your codebase, asks clarifying questions, and creates comprehensive implementation plans. - -### MCP Integration -Connect to databases, APIs, and documentation through the Model Context Protocol. Cline becomes your bridge to any external system. - -### .clinerules -Define project-specific instructions that Cline follows including coding standards, architecture patterns, or team conventions. - -## Why Developers Choose Cline - -**100% Open Source** - Every line of code on GitHub. 48k+ stars from developers who've read it, improved it, and trust it with their work. - -**No Inference Games** - We don't profit from AI usage. While others limit context or route to cheaper models, we give you unrestricted access to any model's full capabilities. - -**Future-Proof by Design** - New model released? Use it immediately. Cline works with any AI provider, any model. - -**True Visibility** - See every file read, every decision considered, every token used. - -## Getting Started - -Ready to experience AI coding without limits? [Install Cline](/getting-started/installing-cline) for your preferred IDE and start with our [Model Selection Guide](/getting-started/model-selection-guide) to choose the right AI model for your needs. diff --git a/docs/getting-started/your-first-project.mdx b/docs/getting-started/your-first-project.mdx new file mode 100644 index 00000000000..575ef648a0d --- /dev/null +++ b/docs/getting-started/your-first-project.mdx @@ -0,0 +1,115 @@ +--- +title: "Build Your First Project" +description: "Build your first project with Cline in under a minute." +--- + +Ready to see Cline in action? This hands-on tutorial will walk you through building a website in under a minute. You'll experience how Cline understands your requirements, creates files, and iterates on your feedback—all through natural conversation. + +By the end of this guide, you'll have built a working website and learned the fundamentals of working with Cline. + +## Prerequisites + +- **Cline installed** in your editor ([Install Guide](/getting-started/installing-cline)) +- **AI model selected** ([Model Setup](/getting-started/selecting-your-model)) +- **Any folder open** in your editor (or create a new empty folder) + +## Step 1: Open Cline + +Click the Cline icon in your editor's sidebar (left side). The chat panel will open. + + +**Quick Tip:** You can also use `Cmd+Shift+P` (Mac) or `Ctrl+Shift+P` (Windows/Linux) and search for "Cline: Open In New Tab" + + +## Step 2: Give Cline a Task + +Copy and paste this prompt into Cline's chat: + +``` +Create a simple website in a single HTML file. It should have: +- A welcome message saying "Hello from Cline!" +- A colorful gradient background +- A button that cycles through different color themes when clicked +- Modern, clean design +- All CSS and JavaScript should be included in the same HTML file +``` + + + Cline Chat Prompt + + +Press Enter and watch Cline work! + +## Step 3: What Happens Next + +Cline will: + +1. **Create a single file:** + - `index.html` - A complete webpage with embedded CSS and JavaScript + +2. **Ask for approval** (unless you've enabled auto-approve) + - Click "Approve" to let Cline create the file + - You can review what it plans to do first + +3. **Complete the task** within seconds + +## Step 4: View Your Website + +Once Cline finishes: + +1. **Find `index.html`** in your editor's file explorer +2. **Right-click it** and select: + - "Reveal in Finder/Explorer" then double-click to open in your browser +3. **Click the button** to see the color themes change! + +## Try Making Changes + +In the same chat, try asking: + +``` +Add a counter that shows how many times the button has been clicked +``` + +or + +``` +Make the welcome message fade in when the page loads +``` + +Cline understands the context from your previous conversation and will update the file accordingly. + + +**You now know how to:** +- Give Cline a task with a clear prompt +- Review and approve Cline's actions +- Build a complete project in seconds +- Iterate and improve on existing work + + +## Next Steps + +Now that you've experienced Cline's capabilities, explore more: + + + +Reference specific files, folders, and URLs in your prompts + + + +Master planning vs. execution for complex tasks + + + +Set project-specific guidelines for consistent results + + + +Learn to write prompts that get the best results + + + +## Need Help? + +- **Stuck?** Try starting fresh with `/new` in the chat +- **Found a bug?** Use `/reportbug` to help us improve +- **Have questions?** Join our [Discord community](https://discord.gg/cline) diff --git a/docs/introduction/overview.mdx b/docs/introduction/overview.mdx new file mode 100644 index 00000000000..3d7e21587b2 --- /dev/null +++ b/docs/introduction/overview.mdx @@ -0,0 +1,147 @@ +--- +title: "Overview" +description: "An introduction to Cline, your AI-powered coding agent for modern development." +--- + +## Open Source AI Coding, Uncompromised + +Cline gives you direct, transparent access to frontier AI with no limits, no surprises, and no model ecosystem lock-in. See every decision. Choose any model. Control your costs. + + + + Watch in real-time as Cline reads files, considers approaches, and proposes changes. Every decision is visible, every edit reviewable before it's made. + + + + Use Claude for complex reasoning, Gemini for massive contexts, or Qwen3 Coder for efficiency. Switch instantly as new models launch. Bring your API keys, your choice. + + + + Your code never touches our servers. Cline runs entirely client-side with your API keys. Open source means your security team can review every line. + + + +## Built for Real Engineering + + + + Work across your entire codebase with intelligent file operations + + + + Run terminal commands and debug errors in real-time + + + + Explore and plan before writing a single line of code + + + + Integrate with databases, APIs, and documentation through MCP servers + + + + Intelligent context management for massive projects + + + + Execute complex workflows from start to finish + + + +## Plan & Act Mode + +Cline explores your codebase and works with you to create comprehensive plans before writing code, ensuring it understands the full context of your project. + + + + For complex tasks, Cline explores your codebase, asks clarifying questions, and creates detailed implementation plans before making changes. + + - Information gathering and context building + - Asking clarifying questions + - Creating detailed execution plans + - Discussing approaches with you + + + + Once you approve the plan, Cline implements the solution with full transparency and control. + + - Executing planned actions + - Using tools to modify files and run commands + - Implementing the solution + - Providing results and completion feedback + + + + +## Key Features + + + +
+
+ Plan & Act Mode • Plan complex features before writing code, then execute with full transparency +
+
+ Focus Chain • Automatic todo list management with real-time progress tracking +
+
+
+ + +
+
+ Auto Approve • Streamline your workflow by automatically approving trusted operations +
+
+ Auto Compact • Automatic conversation summarization to preserve context while freeing space +
+
+ Dictation • Speak naturally to Cline for rapid planning and complex requirements +
+
+
+ + +
+
+ MCP Integration • Connect to databases, APIs, and documentation through the Model Context Protocol +
+
+ Remote Browser • Test and interact with web applications through browser automation +
+
+
+ + +
+
+ .clinerules • Define project-specific instructions including coding standards and patterns +
+
+ Checkpoints • Save and restore project states with Git-based checkpoints +
+
+
+
+ +## Why Developers Choose Cline + + + + Every line of code on GitHub. **50k+ stars** from developers who've used it, improved it, and trust it with their work. + + + + We don't profit from AI usage. While others limit context or route to cheaper models, we give you unrestricted access to any model's full capabilities. + + + + New model released? Use it immediately. Cline works with multiple AI providers. + + + + See every file read, every decision considered, every token used. No black box, no surprises. + + + diff --git a/docs/introduction/welcome.mdx b/docs/introduction/welcome.mdx new file mode 100644 index 00000000000..26ccc7b2360 --- /dev/null +++ b/docs/introduction/welcome.mdx @@ -0,0 +1,54 @@ +--- +title: "Welcome to Cline" +description: "Your guide to AI-powered development with complete transparency and control" +--- + +Cline is an open source AI coding agent that brings frontier AI models directly to your IDE. Unlike autocomplete tools, Cline is a true coding agent that can understand entire codebases, plan complex changes, and execute multi-step tasks. + + +