Compare commits

..

45 Commits

Author SHA1 Message Date
Max Paulus 🥪 0c99fc7ebe improve completion output a bit 2026-01-21 08:48:29 -08:00
Max Paulus 🥪 9c1a16e372 condense task progress messages 2026-01-20 16:39:12 -08:00
Max Paulus 🥪 219bc0621a add image/file support 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 6c8f81e34f cli-webview-adapter refactor 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 0ea3dcd09a auto approval for session and fixed diff view 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 08d15da1a1 copy wasm files in cli-ts 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 683d88fba0 fix tests 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 9409cf9b19 clt auth works a bit better now 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 79df99de35 fix yolo mode again 2026-01-20 16:38:40 -08:00
Max Paulus 🥪 72dd95e74e fix yolo mode 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 257b78e0bc able to use 'cmd "prompt"' now 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 2a4a4c0d46 tab to switch modes 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 3fedf08c7f remove "initializing cline" string 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 93334679d8 create task dump command 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 2d9b7e5e07 fix task resume 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 46f0b77d43 show full task id 2026-01-20 16:38:39 -08:00
Max Paulus 🥪 94e4e320ca better markdown output and usage info 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 f5961d8529 restore command and checkpoint display 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 5eaece8413 fix getDocumentLineCount function and make plan text yello 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 b391ded91d cline-ts bin name
- for npm link command
2026-01-20 16:38:38 -08:00
Max Paulus 🥪 7fbcf4fa31 yolo mode 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 a8df52b34e make a usage slash command 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 118a80ea6c fix ripgrep not found problem 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 d97ef02718 add basic @ completion 2026-01-20 16:38:38 -08:00
Max Paulus 🥪 bdb1638cbd refactor chat command into modular files 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 71d585ab2b fix model selection 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 4b21ca939c fix cline auth command for cline provider 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 c5410701df add loading spinner when output takes a while 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 6a1ac78736 add /config command to chat 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 349158b3d2 /act and /plan commands 2026-01-20 16:38:37 -08:00
Max Paulus 🥪 c4aa50dc45 fix auth command
- auth list wasn't working
2026-01-20 16:38:37 -08:00
Max Paulus 🥪 8f64cf4a34 auth command (partially working) 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 84c02e215e remove some task subcommands 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 d57cbaf63d improve task list command 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 1a54e375a3 improve prompt a bit 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 2eacade09e cline can edit files and show proper output 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 798fc87a96 fix config command 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 c34112b2b7 small code improvements 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 a108ca2154 console filter and better chat output 2026-01-20 16:38:36 -08:00
Max Paulus 🥪 061039c811 task working with ugly output 2026-01-20 16:38:35 -08:00
Max Paulus 🥪 efc2c70a1a more task commands (chat, list, send) 2026-01-20 16:38:35 -08:00
Max Paulus 🥪 c9dd4ae226 task commands 2026-01-20 16:38:35 -08:00
Max Paulus 🥪 d0ebe85535 complete phase 1 and 2 2026-01-20 16:38:35 -08:00
Max Paulus 🥪 d8de30c330 cline auth and config commands (basic) 2026-01-20 16:38:35 -08:00
Max Paulus 🥪 9cd424523c cli-ts 2026-01-20 16:38:35 -08:00
898 changed files with 47906 additions and 379884 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: finalize document content during approval flow
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix decimal input crash in OpenAI Compatible price fields (#8129)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
+13
View File
@@ -0,0 +1,13 @@
---
"claude-dev": patch
---
feat: add OpenAI Codex (ChatGPT Plus/Pro) provider
Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
Models available:
- gpt-5.2-codex (default)
- gpt-5.1-codex-max
- gpt-5.1-codex-mini
- gpt-5.2
@@ -0,0 +1,9 @@
---
"claude-dev": patch
---
Fix two bugs in DiffViewProvider file editing:
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
+9
View File
@@ -0,0 +1,9 @@
---
"claude-dev": patch
---
docs: fix outdated Ollama model names in documentation
Updated recommended Ollama models to use correct identifiers:
- Changed qwen3-coder-30b to qwen2.5-coder:32b
- Changed devstral-small to codellama:34b-code
-5
View File
@@ -1,5 +0,0 @@
---
"cline": patch
---
Always include the latest working directory path in system prompt.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: build complete handlers when upadting the api config
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Updating script documentation and removing unnecessary continue on error
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
This pull request introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness. The feature allows users to seamlessly work with Jupyter notebooks using Cline's AI capabilities while preserving the notebook's JSON structure.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fixed missing provider from list
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
feat(skills): Make skills always enabled and remove feature toggle setting
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Support native tool calling for LM Studio and Ollama provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fixed Favorite Icon / Star from getting clipped in the task history view
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve history view filter menu
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
-33
View File
@@ -1,33 +0,0 @@
# CLI Development
The CLI lives in `cli/` and uses React Ink for terminal UI.
- If needed, look at `cli/src/constants/colors.ts` for re-used terminal colors, e.g. `COLORS.primaryBlue` highlight color (selections, spinners, success states).
- Never use `dimColor` with gray (e.g. `<Text color="gray" dimColor>`) - it's too hard to read. Use `color="gray"` for secondary text and normal foreground (no color) for primary text.
- When thinking about how to handle state or messages from core, look at webview for how it communicates with the vs code extension.
- When updating the webview, consider and suggest to the user to update the CLI TUI since we want to provide a similar experience to our terminal users as we do our vs code extension users.
## Adding New API Providers
When adding a new API provider to the extension, you must also update the CLI:
1. **Update `cli/src/components/ModelPicker.tsx`**: Add the provider to the `providerModels` map so `getDefaultModelId()` returns the correct default model. Import the models and default ID from `@shared/api`:
```typescript
import { newProviderDefaultModelId, newProviderModels } from "@/shared/api"
export const providerModels = {
// ...existing providers
"new-provider": { models: newProviderModels, defaultId: newProviderDefaultModelId },
}
```
2. **Use `applyProviderConfig()` for auth flows**: When implementing OAuth or other auth flows for the provider, use the shared utility at `cli/src/utils/provider-config.ts`:
```typescript
import { applyProviderConfig } from "../utils/provider-config"
// After successful auth:
await applyProviderConfig({ providerId: "new-provider", controller })
```
This handles setting provider, default model, API key mapping, state persistence, and rebuilding the API handler.
3. **Provider-specific auth**: If the provider uses OAuth (like `openai-codex`), add handling in `SettingsPanelContent.tsx`'s `handleProviderSelect` callback. See the existing Codex OAuth flow as a reference.
+7
View File
@@ -16,6 +16,13 @@
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)
# ============================================================================
+1 -13
View File
@@ -44,12 +44,6 @@ jobs:
with:
fetch-depth: 0
- name: Print HEAD commit
run: |
echo "HEAD is at: $(git rev-parse HEAD)"
echo "Short: $(git rev-parse --short HEAD)"
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
- name: Get PR number
id: pr
run: |
@@ -235,13 +229,7 @@ jobs:
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
Start by noting the commit hash you reviewed:
```bash
git rev-parse --short HEAD
```
Include this at the top of your comment: "Reviewed at commit: <short hash>"
Then thank them for their contribution. Be conversational, not robotic.
Start with a warm thank you for their contribution. Be conversational, not robotic.
Include what's relevant:
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author's intent, why they made the changes, how they implemented it, and what files/systems are affected. Don't just summarize - explain.
+1 -13
View File
@@ -48,12 +48,6 @@ jobs:
with:
fetch-depth: 0
- name: Print HEAD commit
run: |
echo "HEAD is at: $(git rev-parse HEAD)"
echo "Short: $(git rev-parse --short HEAD)"
git log -1 --format="Commit: %H%nAuthor: %an <%ae>%nDate: %ad%nMessage: %s"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -275,13 +269,7 @@ jobs:
After your investigation, post a single helpful comment that helps the author and gives maintainers context.
Start by noting the commit hash you reviewed:
```bash
git rev-parse --short HEAD
```
Include this at the top of your comment: "Reviewed at commit: <short hash>"
Then thank them for their contribution. Be conversational, not robotic.
Start with a warm thank you for their contribution. Be conversational, not robotic.
Include what'\''s relevant:
- In-depth explanation of what the PR does - Be comprehensive. A maintainer should be able to read this section and fully understand the author'\''s intent, why they made the changes, how they implemented it, and what files/systems are affected. Don'\''t just summarize - explain.
+2 -5
View File
@@ -80,16 +80,13 @@ jobs:
playwright-browsers-${{ runner.os }}-
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Install vsce
run: npm install -g @vscode/vsce
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
run: sudo apt-get update && sudo apt-get install -y xvfb
+47 -13
View File
@@ -10,8 +10,8 @@ on:
permissions:
contents: read
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
jobs:
test:
@@ -30,8 +30,14 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
@@ -41,21 +47,45 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Install root dependencies and CLI dependencies
if: steps.check_commits.outputs.skip != 'true'
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Generate Protos
run: npm run protos
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Read release version
id: version
run: |
# Read version from cli/package.json
# Read version from cli/package.json (stable version)
VERSION=$(node -p "require('./cli/package.json').version")
echo "Release version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Clean previous builds
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
run: npm run protos && npm run protos-go
- name: Compile CLI
run: npm run compile-cli
- name: Compile CLI for all platforms
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
@@ -67,16 +97,20 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: node scripts/package-npm.mjs
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
run: npm run protos && npm run protos-go
- name: Verify build output
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
+60 -19
View File
@@ -2,13 +2,13 @@ name: Publish NPM Nightly
on:
schedule:
- cron: "0 12 * * *" # 4 AM PST (UTC-8) = 12 UTC
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
permissions:
contents: read
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
jobs:
test:
@@ -39,8 +39,15 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
@@ -51,27 +58,36 @@ jobs:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Install root dependencies and CLI dependencies
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
if: steps.check_commits.outputs.skip != 'true'
run: npm ci --include=optional # this will also install cli deps because "cli" in included in root package.json workspaces field
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Generate Protos
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos
- name: Install root dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Generate nightly version with timestamp
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
# Read base version from cli/package.json (e.g., "2.0.0")
# Read base version from cli/package.json (e.g., "1.0.9")
BASE_VERSION=$(node -p "require('./cli/package.json').version")
# Generate timestamp (Unix epoch seconds)
TIMESTAMP=$(date +%s)
# Create unique nightly version: 1.0.9-nightly.1736365200
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
echo "Base version: $BASE_VERSION"
echo "Generated nightly version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -86,11 +102,31 @@ jobs:
pkg.version = '${{ steps.version.outputs.version }}';
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
"
echo "Using version ${{ steps.version.outputs.version }} for build"
cat cli/package.json | grep '"version"'
- name: Build and package CLI
- name: Download ripgrep binaries
if: steps.check_commits.outputs.skip != 'true'
run: npm run download-ripgrep
- name: Clean previous builds
if: steps.check_commits.outputs.skip != 'true'
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
- name: Compile CLI
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli
- name: Compile CLI for all platforms
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
if: steps.check_commits.outputs.skip != 'true'
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
@@ -102,17 +138,22 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: node scripts/package-npm.mjs
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
+24 -9
View File
@@ -44,11 +44,11 @@ jobs:
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
@@ -89,11 +89,11 @@ jobs:
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Set up NPM on Windows
@@ -180,25 +180,40 @@ jobs:
key: ${{ runner.os }}-npm-testing-platform-${{ hashFiles('testing-platform/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
- name: Build CLI binaries
run: npm run compile-cli-all-platforms
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Compile Standalone
run: npm run compile-standalone
- name: Compile NPM package
run: npm run compile-standalone-npm
- name: Install testing platform dependencies
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
continue-on-error: true
timeout-minutes: 7
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
# Temporarily wrapping the test command to always return a neutral exit code.
# This prevents the job from showing as failed and avoids distracting developers
# until the integration tests are ready to be enforced.
run: |
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
-2
View File
@@ -38,7 +38,6 @@ coverage-unit
src/generated/
src/shared/proto/
webview-ui/src/services/grpc-client.ts
*.tsbuildinfo
# E2E Tests
test-results
@@ -47,4 +46,3 @@ test-results
/pkg
.secrets
*.tsbuildinfo
+2 -74
View File
@@ -1,82 +1,10 @@
# Changelog
## [3.56.0]
### Added
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
### Fixed
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
### Changed
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
- __Settings UI:__ Refreshed feature settings section with collapsible design
## [3.55.0]
- Add new model: Arcee Trinity Large Preview
- Add new model: Moonshot Kimi K2.5
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
## [3.54.0]
### Added
- Native tool calls support for Ollama provider
- Sonnet 4.5 is now the default Amazon Bedrock model id
### Fixed
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
### Changed
- Removed Mistral's Devstral-2512 free from the free models list
- Removed deprecated zai-glm-4.6 model from Cerebras provider
## [3.53.1]
### Fixed
- Bug in responses API
## [3.53.0]
### Fixed
- Removed grok model from free tier
## [3.52.0]
### Added
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
- Grok models are now moving out of free tier and into paid plans.
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
### Fixed
- Bugs in DiffViewProvider for file editing
- Ollama's recommended models to use correct identifiers
## [3.51.0]
### Added
- Adding OpenAI gpt-5.2-codex model to the model picker
- Adding OpenAI gpt-5.2-codex model to the model picker
## [3.50.0]
@@ -1809,4 +1737,4 @@ Add Opus 4.1 through Claude Code
## [0.0.6]
- Initial release
- Initial release
-1
View File
@@ -1,3 +1,2 @@
@.clinerules/general.md
@.clinerules/network.md
@.clinerules/cli.md
-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.317 4.15557C18.7873 3.45369 17.147 2.93658 15.4319 2.6404C15.4007 2.63469 15.3695 2.64897 15.3534 2.67754C15.1424 3.05276 14.9087 3.54225 14.7451 3.927C12.9004 3.65083 11.0652 3.65083 9.25832 3.927C9.09465 3.5337 8.85248 3.05276 8.64057 2.67754C8.62449 2.64992 8.59328 2.63564 8.56205 2.6404C6.84791 2.93563 5.20756 3.45275 3.67693 4.15557C3.66368 4.16129 3.65233 4.17082 3.64479 4.18319C0.533392 8.83155 -0.31895 13.3657 0.0991801 17.8436C0.101072 17.8655 0.11337 17.8864 0.130398 17.8997C2.18321 19.4073 4.17171 20.3225 6.12328 20.9291C6.15451 20.9386 6.18761 20.9272 6.20748 20.9015C6.66913 20.2711 7.08064 19.6063 7.43348 18.9073C7.4543 18.8664 7.43442 18.8178 7.39186 18.8016C6.73913 18.554 6.1176 18.2521 5.51973 17.9093C5.47244 17.8816 5.46865 17.814 5.51216 17.7816C5.63797 17.6873 5.76382 17.5893 5.88396 17.4902C5.90569 17.4721 5.93598 17.4683 5.96153 17.4797C9.88928 19.273 14.1415 19.273 18.023 17.4797C18.0485 17.4674 18.0788 17.4712 18.1015 17.4893C18.2216 17.5883 18.3475 17.6873 18.4742 17.7816C18.5177 17.814 18.5149 17.8816 18.4676 17.9093C17.8697 18.2588 17.2482 18.554 16.5945 18.8006C16.552 18.8168 16.533 18.8664 16.5538 18.9073C16.9143 19.6054 17.3258 20.2701 17.7789 20.9005C17.7978 20.9272 17.8319 20.9386 17.8631 20.9291C19.8241 20.3225 21.8126 19.4073 23.8654 17.8997C23.8834 17.8864 23.8948 17.8664 23.8967 17.8445C24.3971 12.6676 23.0585 8.17064 20.3482 4.18414C20.3416 4.17082 20.3303 4.16129 20.317 4.15557ZM8.02002 15.117C6.8375 15.117 5.86313 14.0313 5.86313 12.6981C5.86313 11.3648 6.8186 10.2791 8.02002 10.2791C9.23087 10.2791 10.1958 11.3743 10.1769 12.6981C10.1769 14.0313 9.22141 15.117 8.02002 15.117ZM15.9947 15.117C14.8123 15.117 13.8379 14.0313 13.8379 12.6981C13.8379 11.3648 14.7933 10.2791 15.9947 10.2791C17.2056 10.2791 18.1705 11.3743 18.1516 12.6981C18.1516 14.0313 17.2056 15.117 15.9947 15.117Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg viewBox="0 0 24 24" fill="black" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.477 2 2 6.477 2 12C2 16.418 4.865 20.166 8.84 21.49C9.34 21.58 9.52 21.27 9.52 21C9.52 20.77 9.51 20.14 9.51 19.31C6.73 19.91 6.14 17.97 6.14 17.97C5.68 16.81 5.03 16.5 5.03 16.5C4.12 15.88 5.1 15.9 5.1 15.9C6.1 15.97 6.63 16.93 6.63 16.93C7.5 18.45 8.97 18 9.54 17.76C9.63 17.11 9.89 16.67 10.17 16.42C7.95 16.17 5.62 15.31 5.62 11.5C5.62 10.39 6 9.5 6.65 8.79C6.55 8.54 6.2 7.5 6.75 6.15C6.75 6.15 7.59 5.88 9.5 7.17C10.29 6.95 11.15 6.84 12 6.84C12.85 6.84 13.71 6.95 14.5 7.17C16.41 5.88 17.25 6.15 17.25 6.15C17.8 7.5 17.45 8.54 17.35 8.79C18 9.5 18.38 10.39 18.38 11.5C18.38 15.32 16.04 16.16 13.81 16.41C14.17 16.72 14.5 17.33 14.5 18.26C14.5 19.6 14.49 20.68 14.49 21C14.49 21.27 14.67 21.59 15.17 21.49C19.14 20.16 22 16.42 22 12C22 6.477 17.523 2 12 2Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 902 B

-10
View File
@@ -1,10 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2001_1428)">
<path d="M22.2234 0H1.77187C0.792187 0 0 0.773438 0 1.72969V22.2656C0 23.2219 0.792187 24 1.77187 24H22.2234C23.2031 24 24 23.2219 24 22.2703V1.72969C24 0.773438 23.2031 0 22.2234 0ZM7.12031 20.4516H3.55781V8.99531H7.12031V20.4516ZM5.33906 7.43438C4.19531 7.43438 3.27188 6.51094 3.27188 5.37187C3.27188 4.23281 4.19531 3.30937 5.33906 3.30937C6.47813 3.30937 7.40156 4.23281 7.40156 5.37187C7.40156 6.50625 6.47813 7.43438 5.33906 7.43438ZM20.4516 20.4516H16.8937V14.8828C16.8937 13.5562 16.8703 11.8453 15.0422 11.8453C13.1906 11.8453 12.9094 13.2937 12.9094 14.7891V20.4516H9.35625V8.99531H12.7687V10.5609H12.8156C13.2891 9.66094 14.4516 8.70938 16.1813 8.70938C19.7859 8.70938 20.4516 11.0813 20.4516 14.1656V20.4516Z" fill="#FAFAFA"/>
</g>
<defs>
<clipPath id="clip0_2001_1428">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 989 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.0512 4.07466C15.3113 5.17727 16.301 5.99866 17.4829 5.99866C18.8627 5.99866 19.9813 4.87965 19.9813 3.49933C19.9813 2.11902 18.8627 1 17.4829 1C16.2764 1 15.2703 1.85537 15.036 2.99314C13.0155 3.20991 11.4378 4.92417 11.4378 7.00167C11.4378 7.00636 11.4378 7.00988 11.4378 7.01456C9.24041 7.10713 7.23397 7.73284 5.641 8.72062C5.04949 8.26247 4.30688 7.98945 3.50102 7.98945C1.5672 7.98945 0 9.55725 0 11.4918C0 12.8955 0.824597 14.1048 2.01581 14.6637C2.13177 18.7297 6.56047 22 12.0082 22C17.4559 22 21.8905 18.7261 22.0006 14.6567C23.1824 14.0942 24 12.8885 24 11.493C24 9.55842 22.4328 7.99063 20.499 7.99063C19.6966 7.99063 18.9575 8.2613 18.3672 8.71594C16.7602 7.72113 14.7315 7.09541 12.5119 7.01222C12.5119 7.0087 12.5119 7.00636 12.5119 7.00285C12.5119 5.51473 13.6176 4.27971 15.0512 4.077V4.07466ZM5.50044 13.7146C5.559 12.4444 6.40234 11.4695 7.38272 11.4695C8.3631 11.4695 9.11274 12.4995 9.05417 13.7697C8.99561 15.0398 8.26354 15.5015 7.28199 15.5015C6.30044 15.5015 5.44187 14.9848 5.50044 13.7146ZM16.6348 11.4695C17.6164 11.4695 18.4597 12.4444 18.5171 13.7146C18.5757 14.9848 17.716 15.5015 16.7356 15.5015C15.7552 15.5015 15.022 15.041 14.9634 13.7697C14.9048 12.4995 15.6533 11.4695 16.6348 11.4695ZM15.4682 16.6533C15.6521 16.6721 15.7693 16.8631 15.6978 17.0341C15.0946 18.4766 13.6703 19.4901 12.0082 19.4901C10.3461 19.4901 8.92299 18.4766 8.31859 17.0341C8.24714 16.8631 8.36427 16.6721 8.54817 16.6533C9.62577 16.5444 10.7912 16.4846 12.0082 16.4846C13.2252 16.4846 14.3895 16.5444 15.4682 16.6533Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3263 1.90393H21.6998L14.3297 10.3274L23 21.7899H16.2112L10.894 14.838L4.80995 21.7899H1.43443L9.31743 12.78L1 1.90393H7.96111L12.7674 8.25826L18.3263 1.90393ZM17.1423 19.7707H19.0116L6.94539 3.81706H4.93946L17.1423 19.7707Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 358 B

+1 -29
View File
@@ -44,7 +44,7 @@
"style": {
"useNodejsImportProtocol": "off",
"useImportType": "off",
"useBlockStatements": "off",
"useBlockStatements": "warn",
"useNamingConvention": "off",
"useThrowOnlyError": "info",
"useConsistentArrayType": "off",
@@ -147,34 +147,6 @@
"src/dev/grit/vscode-api.grit"
]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": [
"src/dev/grit/console-log.grit"
],
"includes": [
"**",
"!**/esbuild.*",
"!**/*.mts",
"!**/webview-ui/**",
"!**/evals/**",
"!**/standalone/**",
"!**/cli/**",
"!**/e2e/**",
"!**/test/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.stories.ts",
"!src/dev/**",
"!**/*.mjs",
"!**/*.js",
"!**/scripts/**",
"!**/*.tsx",
"!**/testing-platform/**",
// ACP mode must redirect console to stderr - this is intentional
"!cli/src/acp/index.ts"
]
},
{
"includes": [
"**",
+18
View File
@@ -0,0 +1,18 @@
{
"extension": [
"ts"
],
"spec": [
"tests/unit/core/**/*.test.ts",
"tests/unit/commands/version.test.ts"
],
"ignore": [
"tests/unit/commands/auth/**",
"tests/unit/commands/task/**"
],
"node-option": [
"import=tsx",
"experimental-specifier-resolution=node"
],
"timeout": 10000
}
+473
View File
@@ -0,0 +1,473 @@
# CLI Features Implementation Plan
Based on the cline.1.md man page, this document outlines the implementation plan for all CLI features. The Cline man page indicates that cline cli runs as a client-server architecture where **Cline Core** runs as a standalone service, but for the typescript version of the CLI, that won't be the case. The Typescript CLI will just import the necessary objects it needs from cline core src directly.
## Overview
The TypeScript CLI scaffold is complete. This plan covers implementing the full feature set from the man page, prioritized by dependency order and user value.
---
## Phase 1: Core Infrastructure (Prerequisites)
**Status: ✅ Completed (tests passing)**
### 1.1 Output Formatting System
**Priority: High** | **Complexity: Medium**
Implement the `-F/--output-format` global option to support `rich`, `json`, and `plain` output formats.
**Files to create:**
- `cli-ts/src/core/output/formatter.ts` - Base formatter interface
- `cli-ts/src/core/output/rich-formatter.ts` - Rich terminal output with colors/styling
- `cli-ts/src/core/output/json-formatter.ts` - JSON output for scripting
- `cli-ts/src/core/output/plain-formatter.ts` - Plain text output
**Types:**
```typescript
interface OutputFormatter {
message(msg: ClineMessage): void
error(err: Error): void
success(text: string): void
table(data: Record<string, unknown>[]): void
list(items: string[]): void
}
interface ClineMessage {
type: 'ask' | 'say'
text: string
ts: number // Unix epoch milliseconds
reasoning?: string
say?: string // say subtype
ask?: string // ask subtype
partial?: boolean
images?: string[]
files?: string[]
lastCheckpointHash?: string
isCheckpointCheckedOut?: boolean
isOperationOutsideWorkspace?: boolean
}
```
**Tests:**
- JSON formatter outputs valid JSON per message
- Rich formatter uses colors when TTY available
- Plain formatter strips all formatting
---
### 1.2 Configuration System
**Priority: High** | **Complexity: Medium**
Implement persistent configuration storage and the `cline config` command group.
**Commands:**
- `cline config set <key> <value>`
- `cline config get <key>`
- `cline config list`
**Files to create:**
- `cli-ts/src/core/config-storage.ts` - Persistent config storage (JSON file in ~/.cline)
- `cli-ts/src/commands/config/index.ts` - Config command group
- `cli-ts/src/commands/config/set.ts`
- `cli-ts/src/commands/config/get.ts`
- `cli-ts/src/commands/config/list.ts`
**Config storage location:** `~/.cline/config.json`
**Tests:**
- Config persists across CLI invocations
- Config values can be overridden
- Invalid keys produce helpful errors
---
### 1.3 Instance Registry & Lifecycle (DEPRECATED -- DO NOT IMPLEMENT)
**Priority: Deprecated** | **Complexity: High**
Implement the instance management system for tracking running Cline Core instances.
**Files to create:**
- `cli-ts/src/core/instance-registry.ts` - Track running instances (SQLite or JSON)
- `cli-ts/src/core/instance-client.ts` - gRPC client for communicating with Cline Core
- `cli-ts/src/commands/instance/index.ts` - Instance command group
- `cli-ts/src/commands/instance/new.ts`
- `cli-ts/src/commands/instance/list.ts`
- `cli-ts/src/commands/instance/default.ts`
- `cli-ts/src/commands/instance/kill.ts`
**Commands:**
- `cline instance new [--default]` / `cline i n`
- `cline instance list` / `cline i l`
- `cline instance default <address>` / `cline i d`
- `cline instance kill <address> [--all]` / `cline i k`
**Architecture notes:**
- The CLI spawns `cline-core` as a child process
- Instances are tracked in `~/.cline/instances.json` with addresses and PIDs
- Default instance is stored in config
**Tests:**
- New instance spawns cline-core process
- List shows all running instances
- Kill terminates specific or all instances
- Default instance is used when --address not specified
---
## Phase 2: Authentication
**Status: ✅ Completed (tests passing)**
### 2.1 Auth Command
**Priority: High** | **Complexity: Medium**
Implement provider authentication system.
**Commands:**
- `cline auth [provider] [key]` / `cline a`
**Files to create:**
- `cli-ts/src/commands/auth/index.ts` - Auth command with interactive wizard
- `cli-ts/src/core/auth/providers.ts` - Provider definitions (Anthropic, OpenRouter, etc.)
- `cli-ts/src/core/auth/wizard.ts` - Interactive provider selection
- `cli-ts/src/core/auth/oauth.ts` - OAuth flow handler (for providers that support it)
**Behavior:**
- No args: Launch interactive wizard
- Provider only: Prompt for key or launch OAuth
- Provider + key: Store key directly
**Storage:** Keys stored in `~/.cline/secrets.json` (with appropriate permissions)
**Tests:**
- Interactive wizard presents provider choices
- API keys are securely stored
- Keys can be updated
---
## Phase 3: Task Management
**Status: ✅ Completed (207 tests passing)**
### 3.1 Task Command Group Base
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
Implement the task command infrastructure.
**Files created:**
- `cli-ts/src/commands/task/index.ts` - Task command group
- `cli-ts/src/core/task-client.ts` - Task storage and management
- `cli-ts/src/types/task.ts` - Task-related types
**Commands:**
- `cline task` / `cline t` - Display help
---
### 3.2 Task Creation & History
**Priority: High** | **Complexity: Medium** | **Status: ✅ Complete**
**Commands:**
- `cline task new <prompt> [options]` / `cline t n`
- `cline task list` / `cline t l` / `cline t ls`
- `cline task open <task-id>` / `cline t o`
**Files created:**
- `cli-ts/src/commands/task/new.ts`
- `cli-ts/src/commands/task/list.ts`
- `cli-ts/src/commands/task/open.ts`
**Options for task new/open:**
- `-s, --setting <key=value>` - Override settings (repeatable)
- `-y, --yolo` / `--no-interactive` - Autonomous mode
- `-m, --mode <mode>` - Starting mode (act/plan)
- `-w, --workspace <path>` - Working directory (new only)
**Options for task list:**
- `-n, --limit <number>` - Limit results (default: 20)
- `-a, --all` - Show all tasks
- `--status <status>` - Filter by status
**Tests (53 new tests):**
- ✅ TaskStorage: create, get, update, delete, list, findByPartialId
- ✅ task new: creates task, validates mode, parses settings
- ✅ task list: shows history, respects limit, filters by status, JSON output
- ✅ task open: finds by full/partial ID, overrides mode/settings, resumes paused tasks
---
### 3.3 Task Communication - Embedded Controller Architecture
**Priority: High** | **Complexity: High**
**Architecture Decision: In-Process Embedded Controller**
The CLI chat REPL will embed the Cline Controller directly in the CLI process (not via gRPC). This approach:
- Uses direct method calls instead of gRPC serialization
- Reuses infrastructure from `src/standalone/cline-core.ts`
- Shares state via `~/.cline/` with VSCode extension
- Outputs to terminal instead of webview
```
┌──────────────────────────────────────────────────────────┐
│ CLI Process │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────┐│
│ │ CLI Chat │───>│ Controller │───>│ Task + AI API ││
│ │ REPL │<───│ │<───│ ││
│ └────────────┘ └────────────┘ └──────────────────┘│
│ │ │ │
│ v v │
│ ┌────────────┐ ┌──────────────┐ │
│ │ Terminal │ │ StateManager │ │
│ │ Output │ │ (~/.cline/) │ │
│ └────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
```
**Key Source Files to Understand:**
- `src/core/controller/index.ts` - Controller class with initTask(), cancelTask(), postStateToWebview()
- `src/standalone/cline-core.ts` - Shows how to run Controller outside VSCode
- `src/standalone/vscode-context.ts` - initializeContext() creates mock ExtensionContext
- `src/standalone/protobus-service.ts` - Shows Controller methods exposed via gRPC
- `src/generated/hosts/standalone/protobus-server-setup.ts` - All available RPC methods
**Key Controller Methods for CLI:**
- `controller.initTask(prompt)` - Start a new task with prompt
- `controller.task?.handleWebviewAskResponse('messageResponse', userInput)` - Send user input
- `controller.task?.messageStateHandler.getClineMessages()` - Get conversation messages
- `controller.cancelTask()` - Cancel current task
- `controller.getStateToPostToWebview()` - Get full state (clineMessages, etc.)
**Commands:**
- `cline task chat` / `cline t c` - Interactive chat mode with embedded Controller
- `cline task send [message] [options]` / `cline t s` - Send single message
- `cline task view [--follow] [--follow-complete]` / `cline t v` - View/stream conversation
**Files to create/modify:**
- `cli-ts/src/core/embedded-controller.ts` - Initialize Controller in CLI process
- `cli-ts/src/core/cli-webview-adapter.ts` - Adapter that outputs to terminal instead of webview
- `cli-ts/src/commands/task/chat.ts` - Updated to use embedded Controller
- `cli-ts/src/commands/task/send.ts` - Updated to use embedded Controller
- `cli-ts/src/commands/task/view.ts` - Updated to use embedded Controller
**Existing file to leverage:**
- `cli-ts/src/core/host-provider-setup.ts` - Already sets up HostProvider for CLI
**Options for task send:**
- `-a, --approve` - Approve proposed action
- `-d, --deny` - Deny proposed action
- `-f, --file <FILE>` - Attach file
- `-y, --no-interactive, --yolo` - Autonomous mode
- `-m, --mode <mode>` - Switch mode
**Options for task view:**
- `-f, --follow` - Stream updates in real-time
- `-c, --follow-complete` - Follow until completion
**Implementation Steps:**
1. Create `embedded-controller.ts` to initialize Controller using:
- `initializeContext()` from `src/standalone/vscode-context.ts`
- `setupHostProvider()` from `cli-ts/src/core/host-provider-setup.ts`
- Direct Controller import from `src/core/controller/index.ts`
2. Create `cli-webview-adapter.ts` to handle state updates:
- Listen to `controller.task?.messageStateHandler` events
- Format ClineMessages for terminal output
- Handle streaming partial messages
3. Update `chat.ts` to use embedded Controller:
- Initialize Controller on command start
- Send prompts via `controller.initTask(prompt)`
- Receive messages via state handler events
- Handle user input via `handleWebviewAskResponse()`
4. Update `send.ts` and `view.ts` similarly
**Tests:**
- Chat mode provides REPL interface with real Controller
- Messages stream to terminal in real-time
- Approve/deny call correct Controller methods
- State persists to ~/.cline/ and is readable by VSCode
---
### 3.4 Task Control
**Priority: Medium** | **Complexity: Medium**
**Commands:**
- `cline task restore <checkpoint>` / `cline t r`
- `cline task pause` / `cline t p`
**Files to create:**
- `cli-ts/src/commands/task/restore.ts`
- `cli-ts/src/commands/task/pause.ts`
**Tests:**
- Restore reverts to checkpoint
- Pause suspends execution
---
## Phase 4: Instant Task Mode
### 4.1 Instant Task Shorthand
**Priority: High** | **Complexity: Medium**
Implement `cline "prompt"` instant task mode that combines instance + task + chat.
**Modify:**
- `cli-ts/src/index.ts` - Detect prompt argument and route to instant task
**Options:**
- `-o, --oneshot` - Complete and stop
- `-s, --setting <key> <value>` - Override settings
- `-y, --no-interactive, --yolo` - Autonomous mode
- `-m, --mode <mode>` - Starting mode
- `-w, --workspace <path>` - Additional workspace paths (can repeat)
**Behavior:**
1. Get or spawn default instance
2. Create new task with prompt
3. Enter chat mode (or oneshot if -o)
**Tests:**
- Instant task spawns instance if needed
- Oneshot completes and exits
- Workspace paths are passed correctly
---
## Phase 5: Global Options Enhancement
### 5.1 Address Flag
**Priority: Medium** | **Complexity: Low**
Add `-a, --address <ADDR>` global option to specify which Cline Core instance to use.
**Modify:**
- `cli-ts/src/index.ts` - Add --address option
- All task commands to use address or default
---
### 5.2 Verbose Flag Enhancement
**Priority: Low** | **Complexity: Low**
Enhance `-v, --verbose` to show debug output including gRPC communication details.
---
## Implementation Order (Recommended)
### Sprint 1: Foundation (Completed)
1. [x] 1.1 Output Formatting System
2. [x] 1.2 Configuration System
3. [x] 2.1 Auth Command
### Sprint 2: Instance Management (Deprecated)
4. [x] 1.3 Instance Registry & Lifecycle (Deprecated, skipped)
### Sprint 3: Task Basics (Completed)
5. [x] 3.1 Task Command Group Base
6. [x] 3.2 Task Creation & History
### Sprint 4: Task Communication (✅ Complete)
7. [x] 3.3 Task Communication (chat, send, view) - Embedded Controller architecture implemented
### Sprint 5: Advanced Features
8. [ ] 4.1 Instant Task Mode
9. [ ] 3.4 Task Control
10. [ ] 5.1 Address Flag
11. [ ] 5.2 Verbose Flag Enhancement
---
## File Structure Summary
```
cli-ts/
├── src/
│ ├── index.ts # Main entry (enhanced)
│ ├── commands/
│ │ ├── version.ts # ✓ Complete
│ │ ├── auth/
│ │ │ └── index.ts
│ │ ├── config/
│ │ │ ├── index.ts
│ │ │ ├── set.ts
│ │ │ ├── get.ts
│ │ │ └── list.ts
│ │ ├── instance/
│ │ │ ├── index.ts
│ │ │ ├── new.ts
│ │ │ ├── list.ts
│ │ │ ├── default.ts
│ │ │ └── kill.ts
│ │ └── task/
│ │ ├── index.ts
│ │ ├── new.ts
│ │ ├── list.ts
│ │ ├── open.ts
│ │ ├── chat.ts
│ │ ├── send.ts
│ │ ├── view.ts
│ │ ├── restore.ts
│ │ └── pause.ts
│ ├── core/
│ │ ├── config.ts # ✓ Complete
│ │ ├── logger.ts # ✓ Complete
│ │ ├── context.ts # ✓ Complete
│ │ ├── host-provider-setup.ts # ✓ Complete
│ │ ├── config-storage.ts # NEW
│ │ ├── instance-registry.ts # NEW
│ │ ├── instance-client.ts # NEW
│ │ ├── task-client.ts # NEW
│ │ ├── output/
│ │ │ ├── formatter.ts
│ │ │ ├── rich-formatter.ts
│ │ │ ├── json-formatter.ts
│ │ │ └── plain-formatter.ts
│ │ └── auth/
│ │ ├── providers.ts
│ │ ├── wizard.ts
│ │ └── oauth.ts
│ └── types/
│ ├── config.ts # ✓ Complete
│ ├── logger.ts # ✓ Complete
│ ├── task.ts # NEW
│ └── message.ts # NEW (ClineMessage)
└── tests/
└── unit/
├── commands/
│ └── version.test.ts # ✓ Complete
├── core/
│ ├── config.test.ts # ✓ Complete
│ └── logger.test.ts # ✓ Complete
└── ... (new tests for each module)
```
---
## Next Steps
1. Start Sprint 3: Task Basics (Task Command Group Base + Task Creation & History)
2. Follow with Sprint 4: Task Communication (chat/send/view)
3. Finish with Sprint 5: Advanced Features (instant task mode, task control, address flag, verbose enhancement)
The plan is designed so each phase delivers working functionality that can be tested independently before moving to the next phase.
### New Task (Phase 3 Kickoff)
**Objective:** Implement Task Command Group Base and Task Creation & History.
**Planned files to create/modify:**
- `cli-ts/src/commands/task/index.ts`
- `cli-ts/src/core/task-client.ts`
- `cli-ts/src/types/task.ts`
- `cli-ts/src/commands/task/new.ts`
- `cli-ts/src/commands/task/list.ts`
- `cli-ts/src/commands/task/open.ts`
- Update `cli-ts/src/index.ts` to register the task command group
**Test requirements:**
- New task creates task in instance
- List shows task history with IDs and snippets
- Open resumes task with saved settings
+305
View File
@@ -0,0 +1,305 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, "..")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
/**
* Alias resolver plugin - resolves path aliases from tsconfig.json
* This is adapted from the root esbuild.mjs to work with the CLI's directory structure
* @type {import('esbuild').Plugin}
*/
/**
* Plugin to resolve 'vscode' imports to the standalone shim
* The shim provides stub implementations for vscode APIs in standalone mode
* @type {import('esbuild').Plugin}
*/
const vscodeShimPlugin = {
name: "vscode-shim",
setup(build) {
const vscodeShimPath = path.resolve(rootDir, "standalone/runtime-files/vscode/index.js")
// Resolve 'vscode' to our virtual shim entry
build.onResolve({ filter: /^vscode$/ }, () => {
return {
path: vscodeShimPath,
// Use sideEffects: false to avoid issues with initialization order
}
})
// The vscode-stubs.js uses implicit global assignment (vscode = {})
// which fails in strict mode. We need to load it without strict mode
// by marking it and its dependencies as external
build.onLoad({ filter: /vscode-stubs\.js$/ }, async (args) => {
const contents = fs.readFileSync(args.path, "utf8")
// Wrap the contents to declare vscode as a local variable
return {
contents: `var vscode;\n${contents}`,
loader: "js",
}
})
},
}
const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
// Aliases point to the root src directory (parent of cli-ts)
const aliases = {
"@": path.resolve(rootDir, "src"),
"@core": path.resolve(rootDir, "src/core"),
"@integrations": path.resolve(rootDir, "src/integrations"),
"@services": path.resolve(rootDir, "src/services"),
"@shared": path.resolve(rootDir, "src/shared"),
"@utils": path.resolve(rootDir, "src/utils"),
"@packages": path.resolve(rootDir, "src/packages"),
"@hosts": path.resolve(rootDir, "src/hosts"),
"@generated": path.resolve(rootDir, "src/generated"),
"@api": path.resolve(rootDir, "src/core/api"),
// CLI-specific aliases
"@cli": path.resolve(__dirname, "src"),
}
// For each alias entry, create a resolver
Object.entries(aliases).forEach(([alias, aliasPath]) => {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
// First, check if the path exists as is
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
// If it's a directory, try to find index files
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const indexFile = path.join(importPath, `index${ext}`)
if (fs.existsSync(indexFile)) {
return { path: indexFile }
}
}
} else {
// It's a file that exists, so return it
return { path: importPath }
}
}
// If the path doesn't exist, try appending extensions
const extensions = [".ts", ".tsx", ".js", ".jsx"]
for (const ext of extensions) {
const pathWithExtension = `${importPath}${ext}`
if (fs.existsSync(pathWithExtension)) {
return { path: pathWithExtension }
}
}
// If nothing worked, return the original path and let esbuild handle the error
return { path: importPath }
})
})
},
}
/**
* Problem matcher plugin for watch mode
*/
const esbuildProblemMatcherPlugin = {
name: "esbuild-problem-matcher",
setup(build) {
build.onStart(() => {
console.log("[watch] build started")
})
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`)
if (location) {
console.error(` ${location.file}:${location.line}:${location.column}:`)
}
})
console.log("[watch] build finished")
})
},
}
/**
* Plugin to copy tree-sitter WASM files to dist directory
* These are required at runtime for code parsing functionality
* @type {import('esbuild').Plugin}
*/
const copyWasmFiles = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
const targetDir = path.resolve(__dirname, "dist")
// Copy tree-sitter.wasm from web-tree-sitter
const treeSitterSource = path.join(rootDir, "node_modules", "web-tree-sitter", "tree-sitter.wasm")
if (fs.existsSync(treeSitterSource)) {
fs.copyFileSync(treeSitterSource, path.join(targetDir, "tree-sitter.wasm"))
} else {
console.warn("Warning: tree-sitter.wasm not found in node_modules/web-tree-sitter")
}
// Copy language-specific WASM files from tree-sitter-wasms
const languageWasmDir = path.join(rootDir, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
if (fs.existsSync(languageWasmDir)) {
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
const sourcePath = path.join(languageWasmDir, filename)
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, path.join(targetDir, filename))
}
})
} else {
console.warn("Warning: tree-sitter-wasms/out directory not found")
}
})
},
}
// Read package.json for version injection
const rootPackageJson = JSON.parse(fs.readFileSync(path.resolve(rootDir, "package.json"), "utf8"))
// Build environment variables
const buildEnvVars = {
"import.meta.url": "_importMetaUrl",
"process.env.IS_STANDALONE": JSON.stringify("true"),
// Inject the Cline version at build time to avoid runtime package.json loading
__CLINE_VERSION__: JSON.stringify(rootPackageJson.version),
}
if (production) {
buildEnvVars["process.env.IS_DEV"] = "false"
}
/**
* Plugin to handle package.json requires by bundling them inline
* This handles JSON files that may have broken relative paths after bundling
* @type {import('esbuild').Plugin}
*/
const jsonResolverPlugin = {
name: "json-resolver",
setup(build) {
// Handle requires to package.json files by resolving and loading them inline
build.onResolve({ filter: /\.json$/ }, (args) => {
// Only handle relative paths
if (args.path.startsWith(".")) {
const resolvedPath = path.resolve(args.resolveDir, args.path)
if (fs.existsSync(resolvedPath)) {
return {
path: resolvedPath,
namespace: "json-inline",
}
}
}
return null
})
// Load JSON files and emit them as CommonJS modules with the JSON data
build.onLoad({ filter: /.*/, namespace: "json-inline" }, (args) => {
const contents = fs.readFileSync(args.path, "utf8")
return {
contents: `module.exports = ${contents}`,
loader: "js",
}
})
},
}
// CLI-specific configuration
const cliConfig = {
entryPoints: [path.resolve(__dirname, "src/index.ts")],
outfile: path.resolve(__dirname, "dist/index.cjs"),
bundle: true,
minify: production,
sourcemap: !production,
logLevel: "silent",
define: buildEnvVars,
tsconfig: path.resolve(__dirname, "tsconfig.json"),
plugins: [vscodeShimPlugin, jsonResolverPlugin, aliasResolverPlugin, copyWasmFiles, esbuildProblemMatcherPlugin],
format: "cjs",
sourcesContent: false,
platform: "node",
loader: {
".json": "json", // Bundle JSON files inline
},
banner: {
js: "const _importMetaUrl=require('url').pathToFileURL(__filename)",
},
// These modules need to load files from the module directory at runtime,
// so they cannot be bundled. Note: vscode is handled by vscodeShimPlugin
// @vscode/ripgrep provides platform-specific binaries that must be resolved at runtime
external: ["@grpc/reflection", "grpc-health-check", "better-sqlite3", "@vscode/ripgrep"],
}
/**
* Copy runtime files needed for standalone mode
* The vscode-context.ts expects package.json at INSTALL_DIR/extension/package.json
*/
async function copyRuntimeFiles() {
const extensionDir = path.resolve(__dirname, "dist/extension")
// Create extension directory
if (!fs.existsSync(extensionDir)) {
fs.mkdirSync(extensionDir, { recursive: true })
}
// Copy package.json from standalone/runtime-files to dist/extension
const sourcePackageJson = path.resolve(rootDir, "standalone/runtime-files/package.json")
const destPackageJson = path.resolve(extensionDir, "package.json")
if (fs.existsSync(sourcePackageJson)) {
fs.copyFileSync(sourcePackageJson, destPackageJson)
} else {
console.warn(`Warning: ${sourcePackageJson} not found, creating minimal package.json`)
// Fallback: create a minimal package.json with version from root
const minimalPackageJson = {
name: "cline",
version: rootPackageJson.version,
displayName: "Cline",
}
fs.writeFileSync(destPackageJson, JSON.stringify(minimalPackageJson, null, 2))
}
}
async function main() {
const ctx = await esbuild.context(cliConfig)
if (watch) {
await ctx.watch()
await copyRuntimeFiles()
console.log("Watching for changes...")
} else {
await ctx.rebuild()
await copyRuntimeFiles()
await ctx.dispose()
console.log("Build completed successfully!")
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+2840
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@cline/cli",
"version": "1.0.0",
"description": "Cline CLI - Command-line interface for Cline AI assistant",
"main": "dist/index.cjs",
"bin": {
"clt": "./dist/index.cjs"
},
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:watch": "node esbuild.mjs --watch",
"build:prod": "node esbuild.mjs --production",
"start": "node dist/index.cjs",
"dev": "node esbuild.mjs && node dist/index.cjs",
"test": "mocha",
"test:watch": "mocha --watch",
"test:coverage": "c8 mocha"
},
"dependencies": {
"@vscode/ripgrep": "^1.15.9",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0"
},
"devDependencies": {
"@types/chai": "^4.3.16",
"@types/marked-terminal": "^6.1.1",
"@types/mocha": "^10.0.7",
"@types/node": "^20.14.10",
"@types/sinon": "^17.0.3",
"c8": "^10.1.2",
"chai": "^4.4.1",
"esbuild": "^0.27.0",
"mocha": "^10.6.0",
"sinon": "^17.0.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
},
"engines": {
"node": ">=20.0.0"
}
}
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
/**
* Config command group - manage persistent CLI configuration
*
* This command uses Cline's StateManager to read/write settings directly,
* ensuring CLI config changes are reflected in the extension and vice versa.
*/
import { Command } from "commander"
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
/**
* Parse a string value into the appropriate type based on the key
*/
export function parseValue(key: string, value: string): unknown {
// Handle boolean values
const lowerValue = value.toLowerCase()
if (lowerValue === "true" || lowerValue === "1" || lowerValue === "yes") {
return true
}
if (lowerValue === "false" || lowerValue === "0" || lowerValue === "no") {
return false
}
// Try to parse as JSON (for arrays and objects)
const trimmed = value.trim()
if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) {
try {
return JSON.parse(value)
} catch {
// If JSON parsing fails, fall through to other parsing
}
}
// Handle numeric values - try to parse as number
const numValue = Number(value)
if (!Number.isNaN(numValue) && value.trim() !== "") {
return numValue
}
// Default: return as string
return value
}
/**
* Get a nested value from an object using dot notation
* e.g., getNestedValue(obj, "browserSettings.viewport.width")
*/
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".")
let current: unknown = obj
for (const part of parts) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
return current
}
/**
* Set a nested value in an object using dot notation
* e.g., setNestedValue(obj, "browserSettings.viewport.width", 1200)
* Returns the modified root object for the top-level key
*/
export function setNestedValue(
obj: Record<string, unknown>,
path: string,
value: unknown,
): { rootKey: string; rootValue: unknown } {
const parts = path.split(".")
const rootKey = parts[0]
if (parts.length === 1) {
// Simple case: top-level key
return { rootKey, rootValue: value }
}
// Clone the root object to avoid mutating the original
const rootValue = JSON.parse(JSON.stringify(obj[rootKey] ?? {}))
// Navigate to the parent of the target, creating objects as needed
let current = rootValue as Record<string, unknown>
for (let i = 1; i < parts.length - 1; i++) {
const part = parts[i]
if (current[part] === undefined || current[part] === null || typeof current[part] !== "object") {
current[part] = {}
}
current = current[part] as Record<string, unknown>
}
// Set the final value
current[parts[parts.length - 1]] = value
return { rootKey, rootValue }
}
/**
* Create the config set command
*/
function createConfigSetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
return new Command("set")
.description("Set a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
.argument("<key>", "Configuration key to set")
.argument("<value>", "Value to set")
.action(async (key: string, value: string) => {
logger.debug(`Setting config: ${key} = ${value}`)
try {
// Initialize embedded controller to access StateManager
const controller = await getEmbeddedController(logger, config.configDir)
// Parse value to appropriate type
const parsedValue = parseValue(key, value)
// Check if this is a nested path
if (key.includes(".")) {
// For nested paths, get the current root object, modify it, and save the whole thing
const rootKey = key.split(".")[0]
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
if (rootValue === undefined) {
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
}
// Build the updated root object
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, key, parsedValue)
// Save the updated root object
controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
} else {
// Simple top-level key
controller.stateManager.setGlobalState(key as any, parsedValue as any)
}
// Flush pending state to ensure changes are persisted before exit
await controller.stateManager.flushPendingState()
formatter.success(`Set ${key} = ${String(parsedValue)}`)
// Cleanup and exit
await disposeEmbeddedController(logger)
process.exit(0)
} catch (err) {
formatter.error(err as Error)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
}
/**
* Create the config get command
*/
function createConfigGetCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
return new Command("get")
.description("Get a configuration value (supports dot notation for nested values, e.g., browserSettings.viewport.width)")
.argument("<key>", "Configuration key to get")
.action(async (key: string) => {
logger.debug(`Getting config: ${key}`)
try {
// Initialize embedded controller to access StateManager
const controller = await getEmbeddedController(logger, config.configDir)
let value: unknown
// Check if this is a nested path
if (key.includes(".")) {
// For nested paths, get the root object first
const rootKey = key.split(".")[0]
let rootValue = controller.stateManager.getGlobalSettingsKey(rootKey as any)
if (rootValue === undefined) {
rootValue = controller.stateManager.getGlobalStateKey(rootKey as any)
}
if (rootValue !== undefined && typeof rootValue === "object") {
// Get the nested value
value = getNestedValue({ [rootKey]: rootValue }, key)
}
} else {
// Simple top-level key
value = controller.stateManager.getGlobalSettingsKey(key as any)
if (value === undefined) {
value = controller.stateManager.getGlobalStateKey(key as any)
}
}
if (value === undefined) {
formatter.info(`${key} is not set`)
} else {
// Format objects/arrays as JSON for display
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : value
formatter.keyValue({ [key]: displayValue })
}
// Cleanup and exit
await disposeEmbeddedController(logger)
process.exit(0)
} catch (err) {
formatter.error(err as Error)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
}
/**
* Create the config list command
*/
function createConfigListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
return new Command("list").description("List all configuration values").action(async () => {
logger.debug("Listing all config")
try {
// Read the globalState.json file directly to get all settings
const fs = await import("fs")
const path = await import("path")
const globalStatePath = path.join(config.configDir || `${process.env.HOME}/.cline`, "data", "globalState.json")
let allSettings: Record<string, unknown> = {}
if (fs.existsSync(globalStatePath)) {
const content = fs.readFileSync(globalStatePath, "utf-8")
allSettings = JSON.parse(content)
}
formatter.raw("")
formatter.raw(JSON.stringify(allSettings, null, 2))
formatter.raw("")
// Cleanup and exit
process.exit(0)
} catch (err) {
formatter.error(err as Error)
process.exit(1)
}
})
}
/**
* Create the config delete command
*/
function createConfigDeleteCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
return new Command("delete")
.alias("rm")
.description("Delete a configuration value (reset to default)")
.argument("<key>", "Configuration key to delete")
.action(async (key: string) => {
logger.debug(`Deleting config: ${key}`)
try {
// Initialize embedded controller to access StateManager
const controller = await getEmbeddedController(logger, config.configDir)
// Set the value to undefined to reset to default
// Using type assertion since key is dynamic
controller.stateManager.setGlobalState(key as any, undefined)
// Flush pending state to ensure changes are persisted before exit
await controller.stateManager.flushPendingState()
formatter.success(`Reset ${key} to default`)
// Cleanup and exit
await disposeEmbeddedController(logger)
process.exit(0)
} catch (err) {
formatter.error(err as Error)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
}
/**
* Create the config command group
*/
export function createConfigCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const configCommand = new Command("config").alias("c").description("Manage CLI configuration")
configCommand.addCommand(createConfigSetCommand(config, logger, formatter))
configCommand.addCommand(createConfigGetCommand(config, logger, formatter))
configCommand.addCommand(createConfigListCommand(config, logger, formatter))
configCommand.addCommand(createConfigDeleteCommand(config, logger, formatter))
return configCommand
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Task chat command - interactive REPL mode with embedded Controller
*
* This command provides an interactive chat interface using Cline's
* embedded Controller, allowing real-time AI interactions directly
* from the terminal.
*/
import { Command } from "commander"
import { disposeEmbeddedController, getEmbeddedController } from "../../../core/embedded-controller.js"
import type { OutputFormatter } from "../../../core/output/types.js"
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../../core/path-parser.js"
import type { CliConfig } from "../../../types/config.js"
import type { Logger } from "../../../types/logger.js"
import { startRepl } from "./repl.js"
import { createSession } from "./session.js"
/**
* Collect multiple option values into an array
* Used for -f and -i options that can be specified multiple times
*/
function collectOption(value: string, previous: string[]): string[] {
return previous.concat([value])
}
/**
* Create the task chat command
*/
export function createTaskChatCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const chatCommand = new Command("chat")
.alias("c")
.description("Interactive chat mode with embedded Cline Controller")
.argument("[prompt]", "Initial prompt to start a new task (optional)")
.option("-m, --mode <mode>", "Start in specific mode: act or plan")
.option("-t, --task <id>", "Resume an existing task by ID")
.option("-f, --file <path>", "Attach file to initial prompt (can be repeated)", collectOption, [])
.option("-i, --image <path>", "Attach image to initial prompt (can be repeated)", collectOption, [])
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
.action(async (promptArg: string | undefined, options) => {
logger.debug("Task chat command called", { promptArg, options })
try {
// Process explicit file and image attachments from CLI options
const cwd = process.cwd()
let initialFiles: string[] = []
let initialImages: string[] = []
// Process -f/--file options (can be files or images, auto-detected)
if (options.file && options.file.length > 0) {
const processed = processExplicitFiles(options.file, cwd)
initialFiles = processed.files
initialImages = processed.images
}
// Process -i/--image options (must be images)
if (options.image && options.image.length > 0) {
const images = processExplicitImages(options.image, cwd)
initialImages = initialImages.concat(images)
}
// Parse @path references from the initial prompt if provided
let processedPrompt = promptArg
if (promptArg) {
const parsed = parseAtPaths(promptArg, cwd)
// Show warnings for any files that couldn't be processed
for (const warning of parsed.warnings) {
formatter.warn(warning)
}
processedPrompt = parsed.cleanedMessage
initialFiles = initialFiles.concat(parsed.files)
initialImages = initialImages.concat(parsed.images)
}
// Initialize embedded controller
const controller = await getEmbeddedController(logger, config.configDir)
// Set up mode if specified
if (options.mode) {
if (options.mode !== "plan" && options.mode !== "act") {
throw new Error(`Invalid mode: "${options.mode}". Valid options are: act, plan`)
}
await controller.togglePlanActMode(options.mode as "plan" | "act")
}
if (options.yolo) {
controller.stateManager.setGlobalState("yoloModeToggled", true)
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose Plan mode)
if (!options.mode) {
await controller.togglePlanActMode("act")
}
}
// Create chat session with yolo mode if specified
const session = createSession(options.yolo)
if (options.yolo) {
formatter.info("[YOLO] Autonomous mode enabled - no confirmations required")
}
// Start the REPL
await startRepl({
session,
controller,
formatter,
logger,
config,
initialPrompt: processedPrompt,
initialImages: initialImages.length > 0 ? initialImages : undefined,
initialFiles: initialFiles.length > 0 ? initialFiles : undefined,
resumeTaskId: options.task,
})
} catch (error) {
formatter.error((error as Error).message)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
return chatCommand
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Tab completion for @ file/folder mentions in chat REPL
*
* Provides file and folder path completion when users type @ followed by a partial path.
*/
import fs from "fs"
import path from "path"
/**
* Result of finding an @ mention to complete
*/
interface AtMentionMatch {
/** The text before the @ mention (to preserve in completion) */
prefix: string
/** The partial path after @ that needs completion */
partial: string
/** Character index where the @ starts */
atIndex: number
}
/**
* Find the @ mention being completed in the input line
*
* Handles multiple @ mentions by finding the last one that appears
* to be incomplete (user is still typing it).
*/
function findAtMentionToComplete(line: string): AtMentionMatch | null {
// Find the last @ that could be a file mention
// We look for @ that's either at start or preceded by whitespace
let atIndex = -1
for (let i = line.length - 1; i >= 0; i--) {
if (line[i] === "@") {
// Check if it's at start or preceded by whitespace
if (i === 0 || /\s/.test(line[i - 1])) {
atIndex = i
break
}
}
}
if (atIndex === -1) {
return null
}
// Extract the partial path after @
const afterAt = line.slice(atIndex + 1)
// If there's whitespace after @, this mention is complete, not being typed
if (/\s/.test(afterAt)) {
return null
}
return {
prefix: line.slice(0, atIndex),
partial: afterAt,
atIndex,
}
}
/**
* Get completions for a partial file/folder path
*/
function getPathCompletions(partial: string, cwd: string): string[] {
try {
// Determine the directory to search and the prefix to match
let searchDir: string
let namePrefix: string
if (partial === "") {
// Empty partial - list cwd contents
searchDir = cwd
namePrefix = ""
} else if (partial.endsWith("/")) {
// Ends with / - list that directory's contents
searchDir = path.resolve(cwd, partial)
namePrefix = ""
} else {
// Partial filename - list parent directory and filter
const partialPath = path.resolve(cwd, partial)
searchDir = path.dirname(partialPath)
namePrefix = path.basename(partial)
}
// Check if directory exists
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
return []
}
// Read directory contents
const entries = fs.readdirSync(searchDir, { withFileTypes: true })
// Filter and map entries
const completions: string[] = []
for (const entry of entries) {
// Skip hidden files unless explicitly searching for them
if (entry.name.startsWith(".") && !namePrefix.startsWith(".")) {
continue
}
// Check if name matches prefix
if (!entry.name.toLowerCase().startsWith(namePrefix.toLowerCase())) {
continue
}
// Build the completion path
let completionPath: string
if (partial === "") {
completionPath = entry.name
} else if (partial.endsWith("/")) {
completionPath = partial + entry.name
} else {
// Replace the partial filename with the full name
const dirPart = partial.slice(0, partial.length - namePrefix.length)
completionPath = dirPart + entry.name
}
// Append / for directories
if (entry.isDirectory()) {
completionPath += "/"
}
completions.push(completionPath)
}
// Sort: directories first, then alphabetically
completions.sort((a, b) => {
const aIsDir = a.endsWith("/")
const bIsDir = b.endsWith("/")
if (aIsDir && !bIsDir) return -1
if (!aIsDir && bIsDir) return 1
return a.localeCompare(b)
})
return completions
} catch {
// If anything goes wrong, return no completions
return []
}
}
/**
* Options for creating a completer
*/
export interface CompleterOptions {
/** The current working directory for path resolution */
cwd: string
/** Callback invoked when Tab is pressed on an empty line */
onEmptyTab?: () => void
}
/**
* Create a readline completer function for @ file mentions
*
* Also supports triggering a callback when Tab is pressed on an empty line,
* which is used for mode toggling.
*
* @param options - Completer options including cwd and callbacks
* @returns A completer function compatible with readline
*/
export function createCompleter(options: CompleterOptions): (line: string) => [string[], string] {
const { cwd, onEmptyTab } = options
return (line: string): [string[], string] => {
// Check for empty input - trigger mode toggle callback if provided
if (line === "" && onEmptyTab) {
onEmptyTab()
return [[], line]
}
const match = findAtMentionToComplete(line)
if (!match) {
// No @ mention being typed - no completions
return [[], line]
}
const pathCompletions = getPathCompletions(match.partial, cwd)
if (pathCompletions.length === 0) {
return [[], line]
}
// Build full line completions (prefix + @ + completed path)
const fullCompletions = pathCompletions.map((p) => `${match.prefix}@${p}`)
// The "substring" is what readline uses to determine what to replace
// We want to replace from the @ onwards
const substring = `@${match.partial}`
// Return format: [completions, substring being completed]
// If there's only one completion, readline will auto-complete
// If multiple, it will show them as options
return [fullCompletions, line]
}
}
// Export for testing
export { findAtMentionToComplete, getPathCompletions }
+13
View File
@@ -0,0 +1,13 @@
/**
* Chat command module
*
* Re-exports the main command factory for backward compatibility.
*/
export { createTaskChatCommand } from "./command.js"
// Also export utilities for testing
export { checkForPendingInput, isCompletionState, isFailureState, type PendingInputState } from "./input-checker.js"
export { getModelIdForProvider, getModelIdKey } from "./model-utils.js"
export { buildPromptString } from "./prompt.js"
export { type ChatSession, createSession } from "./session.js"
@@ -0,0 +1,168 @@
/**
* Input state checker for chat REPL
*
* Analyzes message history to determine if user input is needed.
*/
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
/**
* Auto-approval action keys that can be enabled for "don't ask again" functionality
*/
export type AutoApprovalAction = "readFiles" | "editFiles" | "executeAllCommands" | "useBrowser" | "useMcp"
/**
* Result of checking for pending input
*/
export interface PendingInputState {
awaitingApproval: boolean
awaitingInput: boolean
}
/**
* Check if the last message requires user input
*/
export function checkForPendingInput(messages: ClineMessage[]): PendingInputState {
if (messages.length === 0) {
return { awaitingApproval: false, awaitingInput: false }
}
const lastMessage = messages[messages.length - 1]
// Skip partial messages
if (lastMessage.partial) {
return { awaitingApproval: false, awaitingInput: false }
}
// Check if this is an "ask" type message
if (lastMessage.type === "ask") {
const ask = lastMessage.ask
// These require approval (yes/no response)
const approvalAsks = ["command", "tool", "browser_action_launch", "use_mcp_server"]
// These require free-form input
const inputAsks = ["followup", "plan_mode_respond", "act_mode_respond"]
if (approvalAsks.includes(ask || "")) {
return { awaitingApproval: true, awaitingInput: false }
}
if (inputAsks.includes(ask || "")) {
return { awaitingApproval: false, awaitingInput: true }
}
// Special cases
if (ask === "api_req_failed") {
return { awaitingApproval: true, awaitingInput: false }
}
if (ask === "completion_result" || ask === "resume_task" || ask === "resume_completed_task") {
return { awaitingApproval: false, awaitingInput: true }
}
}
return { awaitingApproval: false, awaitingInput: false }
}
/**
* Check if the last message indicates a failure state (for yolo mode)
*/
export function isFailureState(messages: ClineMessage[]): { isFailure: boolean; actionKey: string | null } {
if (messages.length === 0) {
return { isFailure: false, actionKey: null }
}
const lastMessage = messages[messages.length - 1]
// Skip partial messages
if (lastMessage.partial) {
return { isFailure: false, actionKey: null }
}
// Check for failure indicators
if (
lastMessage.ask === "api_req_failed" ||
lastMessage.ask === "mistake_limit_reached" ||
lastMessage.say === "error" ||
lastMessage.say === "diff_error"
) {
// Use the message text as the action key for tracking consecutive failures
const actionKey = lastMessage.text || lastMessage.ask || lastMessage.say || "unknown"
return { isFailure: true, actionKey }
}
return { isFailure: false, actionKey: null }
}
/**
* Check if the last message indicates task completion (for yolo mode)
*/
export function isCompletionState(messages: ClineMessage[]): boolean {
if (messages.length === 0) {
return false
}
const lastMessage = messages[messages.length - 1]
// Skip partial messages
if (lastMessage.partial) {
return false
}
return lastMessage.ask === "completion_result" || lastMessage.say === "completion_result"
}
/**
* Determine which auto-approval action to enable based on the ask message
*
* @param msg - The pending ask message
* @returns The auto-approval action key, or null if not applicable
*/
export function determineAutoApprovalAction(msg: ClineMessage): AutoApprovalAction | null {
const ask = msg.ask
switch (ask) {
case "tool": {
// Parse tool message to determine if it's a read or edit operation
if (!msg.text) {
return null
}
try {
const tool = JSON.parse(msg.text) as ClineSayTool
switch (tool.tool) {
case "readFile":
case "listFilesTopLevel":
case "listFilesRecursive":
case "listCodeDefinitionNames":
case "searchFiles":
case "webFetch":
case "webSearch":
return "readFiles"
case "editedExistingFile":
case "newFileCreated":
return "editFiles"
case "fileDeleted":
// File deletion uses editFiles permission
return "editFiles"
default:
return null
}
} catch {
return null
}
}
case "command":
return "executeAllCommands"
case "browser_action_launch":
return "useBrowser"
case "use_mcp_server":
return "useMcp"
default:
return null
}
}
@@ -0,0 +1,135 @@
/**
* Model ID utilities for chat command
*
* Functions to map providers to their corresponding model ID configuration keys.
*/
import type { ApiConfiguration, ApiProvider } from "@shared/api"
import type { Mode } from "@shared/storage/types"
/**
* Get the model ID for the current provider and mode
*/
export function getModelIdForProvider(
apiConfiguration: ApiConfiguration | undefined,
provider: ApiProvider | undefined,
mode: Mode,
): string | undefined {
if (!apiConfiguration || !provider) {
return undefined
}
const prefix = mode === "plan" ? "planMode" : "actMode"
// Map provider to the corresponding model ID field
switch (provider) {
case "openrouter":
case "cline":
return apiConfiguration[`${prefix}OpenRouterModelId`]
case "anthropic":
case "claude-code":
case "bedrock":
case "vertex":
case "gemini":
case "openai-native":
case "deepseek":
case "qwen":
case "qwen-code":
case "doubao":
case "mistral":
case "asksage":
case "xai":
case "moonshot":
case "nebius":
case "sambanova":
case "cerebras":
case "sapaicore":
case "zai":
case "fireworks":
case "minimax":
return apiConfiguration[`${prefix}ApiModelId`]
case "openai":
return apiConfiguration[`${prefix}OpenAiModelId`]
case "ollama":
return apiConfiguration[`${prefix}OllamaModelId`]
case "lmstudio":
return apiConfiguration[`${prefix}LmStudioModelId`]
case "requesty":
return apiConfiguration[`${prefix}RequestyModelId`]
case "together":
return apiConfiguration[`${prefix}TogetherModelId`]
case "litellm":
return apiConfiguration[`${prefix}LiteLlmModelId`]
case "groq":
return apiConfiguration[`${prefix}GroqModelId`]
case "baseten":
return apiConfiguration[`${prefix}BasetenModelId`]
case "huggingface":
return apiConfiguration[`${prefix}HuggingFaceModelId`]
case "huawei-cloud-maas":
return apiConfiguration[`${prefix}HuaweiCloudMaasModelId`]
case "oca":
return apiConfiguration[`${prefix}OcaModelId`]
case "hicap":
return apiConfiguration[`${prefix}HicapModelId`]
case "aihubmix":
return apiConfiguration[`${prefix}AihubmixModelId`]
case "nousResearch":
return apiConfiguration[`${prefix}NousResearchModelId`]
case "vercel-ai-gateway":
return apiConfiguration[`${prefix}VercelAiGatewayModelId`]
case "vscode-lm":
case "dify":
default:
return undefined
}
}
/**
* Get the model ID state key for a given provider and mode
* Some providers use provider-specific model ID keys (e.g., openRouterModelId),
* while others use the generic apiModelId
*/
export function getModelIdKey(provider: string | undefined, mode: Mode): string {
const modePrefix = mode === "plan" ? "planMode" : "actMode"
switch (provider) {
case "openrouter":
case "cline":
return `${modePrefix}OpenRouterModelId`
case "openai":
return `${modePrefix}OpenAiModelId`
case "ollama":
return `${modePrefix}OllamaModelId`
case "lmstudio":
return `${modePrefix}LmStudioModelId`
case "litellm":
return `${modePrefix}LiteLlmModelId`
case "requesty":
return `${modePrefix}RequestyModelId`
case "together":
return `${modePrefix}TogetherModelId`
case "fireworks":
return `${modePrefix}FireworksModelId`
case "groq":
return `${modePrefix}GroqModelId`
case "baseten":
return `${modePrefix}BasetenModelId`
case "huggingface":
return `${modePrefix}HuggingFaceModelId`
case "huawei-cloud-maas":
return `${modePrefix}HuaweiCloudMaasModelId`
case "oca":
return `${modePrefix}OcaModelId`
case "hicap":
return `${modePrefix}HicapModelId`
case "aihubmix":
return `${modePrefix}AihubmixModelId`
case "nousResearch":
return `${modePrefix}NousResearchModelId`
case "vercel-ai-gateway":
return `${modePrefix}VercelAiGatewayModelId`
default:
return `${modePrefix}ApiModelId`
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Prompt string builder for chat REPL
*
* Builds the CLI prompt that shows current mode, provider, and model.
*/
import type { ApiProvider } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import chalk from "chalk"
/**
* Build the CLI prompt string with mode, provider, and model
* Format: [mode] provider/model >
*/
export function buildPromptString(mode: Mode, provider: ApiProvider | undefined, modelId: string | undefined): string {
const modeStr = mode === "plan" ? chalk.yellow("[plan]") : chalk.cyan("[act]")
const providerStr = provider || "unknown"
// Shorten very long model IDs for display (keep last part after last /)
let modelStr = modelId || "unknown"
if (modelStr.length > 40) {
const lastSlash = modelStr.lastIndexOf("/")
if (lastSlash > 0 && lastSlash < modelStr.length - 1) {
modelStr = "..." + modelStr.substring(lastSlash)
} else {
modelStr = modelStr.substring(0, 37) + "..."
}
}
const providerModelStr = chalk.dim(`${providerStr}/${modelStr}`)
return `${modeStr} ${providerModelStr} ${chalk.white(">")} `
}
+491
View File
@@ -0,0 +1,491 @@
/**
* REPL (Read-Eval-Print Loop) for chat command
*
* Handles readline setup, event handling, and the main interaction loop.
*/
import type { ApiProvider } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import readline from "readline"
import type { Controller } from "@/core/controller"
import { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
import { disposeEmbeddedController } from "../../../core/embedded-controller.js"
import type { OutputFormatter } from "../../../core/output/types.js"
import { parseAtPaths } from "../../../core/path-parser.js"
import type { CliConfig } from "../../../types/config.js"
import type { Logger } from "../../../types/logger.js"
import { createCompleter } from "./completer.js"
import { checkForPendingInput, determineAutoApprovalAction, isCompletionState, isFailureState } from "./input-checker.js"
import { getModelIdForProvider } from "./model-utils.js"
import { buildPromptString } from "./prompt.js"
import type { ChatSession } from "./session.js"
import { processSlashCommand } from "./slash-commands/index.js"
/** Yolo mode timeout: 5 minutes in milliseconds */
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
/** Yolo mode max consecutive failures before abort */
const YOLO_MAX_FAILURES = 3
/**
* Options for starting the REPL
*/
export interface ReplOptions {
session: ChatSession
controller: Controller
formatter: OutputFormatter
logger: Logger
config: CliConfig
initialPrompt?: string
initialImages?: string[]
initialFiles?: string[]
resumeTaskId?: string
}
/**
* Start the interactive REPL loop
*/
export async function startRepl(options: ReplOptions): Promise<void> {
const { session, controller, formatter, logger, config, initialPrompt, initialImages, initialFiles, resumeTaskId } = options
// Create webview adapter for output
session.adapter = new CliWebviewAdapter(controller, formatter)
// Track if we started with a prompt (AI will be processing)
let startedWithPrompt = false
// Start or resume task
if (resumeTaskId) {
// Resume existing task
const history = await controller.getTaskWithId(resumeTaskId)
if (!history) {
throw new Error(`Task not found: ${resumeTaskId}`)
}
session.taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
formatter.info(`Resumed task: ${session.taskId}`)
} else if (initialPrompt) {
// Start new task with prompt and any initial attachments
startedWithPrompt = true
// Log attachment info
if (initialFiles && initialFiles.length > 0) {
formatter.info(`Attaching ${initialFiles.length} file(s)`)
}
if (initialImages && initialImages.length > 0) {
formatter.info(`Attaching ${initialImages.length} image(s)`)
}
session.taskId = await controller.initTask(
initialPrompt,
initialImages && initialImages.length > 0 ? initialImages : undefined,
initialFiles && initialFiles.length > 0 ? initialFiles : undefined,
)
formatter.info(`Started task: ${session.taskId}`)
// Enable spinner since AI will be processing
session.adapter?.setProcessing(true)
}
// Display welcome message
displayWelcome(formatter, session, controller)
// Output existing messages if resuming
if (session.taskId && session.adapter) {
session.adapter.outputAllMessages()
}
// Helper to toggle between act and plan mode
const toggleMode = async (): Promise<void> => {
// Only toggle when awaiting user input (not while AI is processing)
if (isProcessing) {
return
}
const state = await controller.getStateToPostToWebview()
const currentMode = (state.mode || "act") as Mode
const newMode = currentMode === "act" ? "plan" : "act"
await controller.togglePlanActMode(newMode)
await updatePromptString()
showPrompt()
}
// Create readline interface with @ file completion and mode toggle on empty Tab
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "> ", // Default prompt, will be updated dynamically
completer: createCompleter({
cwd: process.cwd(),
onEmptyTab: () => {
// Use setImmediate to allow async operation outside completer
setImmediate(() => toggleMode())
},
}),
})
// Track if we're currently processing (AI is working)
let isProcessing = startedWithPrompt
// Track previous awaiting states to detect transitions
let wasAwaitingInput = false
// Helper to set processing state and update spinner
function setProcessingState(processing: boolean): void {
isProcessing = processing
session.adapter?.setProcessing(processing)
}
// Helper to update the prompt string (but not necessarily show it)
async function updatePromptString(): Promise<void> {
const currentState = await controller.getStateToPostToWebview()
const mode = (currentState.mode || "act") as Mode
const provider = (
mode === "plan"
? currentState.apiConfiguration?.planModeApiProvider
: currentState.apiConfiguration?.actModeApiProvider
) as ApiProvider | undefined
const modelId = getModelIdForProvider(currentState.apiConfiguration, provider, mode)
const promptStr = buildPromptString(mode, provider, modelId)
rl.setPrompt(promptStr)
}
// Helper to show the prompt (call after updating)
function showPrompt(): void {
rl.prompt()
}
// Start listening for state updates
session.adapter.startListening((messages) => {
const pendingState = checkForPendingInput(messages)
session.awaitingApproval = pendingState.awaitingApproval
session.awaitingInput = pendingState.awaitingInput
// Store the pending ask message for auto-approval determination
if (pendingState.awaitingApproval && messages.length > 0) {
const lastMessage = messages[messages.length - 1]
if (lastMessage.type === "ask" && !lastMessage.partial) {
session.pendingAskMessage = lastMessage
}
} else if (!pendingState.awaitingApproval) {
session.pendingAskMessage = null
}
// YOLO MODE: Auto-respond to pending inputs
if (session.yoloMode && controller.task && !session.yoloCompleted) {
// Check for task completion first
if (isCompletionState(messages)) {
// Guard against processing completion multiple times
session.yoloCompleted = true
formatter.success("\n[YOLO] Task completed!")
// Respond to the completion_result ask to unblock the handler
const task = controller.task
task.handleWebviewAskResponse("yesButtonClicked")
// Schedule exit after brief delay to let response process
setTimeout(async () => {
try {
await task.abortTask()
} catch {
// Task may already be cleaned up, ignore
}
// Exit successfully - task has completed
process.exit(0)
}, 200)
return
}
// Check for timeout (5 minutes)
if (session.yoloActionStartTime && Date.now() - session.yoloActionStartTime > YOLO_TIMEOUT_MS) {
formatter.error("\n[YOLO] Action timed out after 5 minutes. Aborting.")
session.isRunning = false
rl.close()
process.exit(0)
return
}
// Check for failure state
const failureCheck = isFailureState(messages)
if (failureCheck.isFailure) {
if (session.yoloLastFailedAction === failureCheck.actionKey) {
session.yoloFailureCount++
} else {
session.yoloLastFailedAction = failureCheck.actionKey
session.yoloFailureCount = 1
}
if (session.yoloFailureCount >= YOLO_MAX_FAILURES) {
formatter.error(`\n[YOLO] Same action failed ${YOLO_MAX_FAILURES} times. Aborting.`)
session.isRunning = false
rl.close()
process.exit(0)
return
}
// Auto-retry: approve the retry
formatter.warn(`[YOLO] Action failed (attempt ${session.yoloFailureCount}/${YOLO_MAX_FAILURES}), retrying...`)
session.yoloActionStartTime = Date.now()
setProcessingState(true)
wasAwaitingInput = false
controller.task.handleWebviewAskResponse("yesButtonClicked")
return
} else {
// Reset failure tracking on success
session.yoloFailureCount = 0
session.yoloLastFailedAction = null
}
// Auto-approve pending approvals
if (pendingState.awaitingApproval) {
logger.debug("[YOLO] Auto-approving action")
session.yoloActionStartTime = Date.now()
setProcessingState(true)
wasAwaitingInput = false
controller.task.handleWebviewAskResponse("yesButtonClicked")
session.awaitingApproval = false
return
}
// Auto-respond to input requests with "proceed"
if (pendingState.awaitingInput) {
logger.debug("[YOLO] Auto-responding with 'proceed'")
session.yoloActionStartTime = Date.now()
setProcessingState(true)
wasAwaitingInput = false
controller.task.handleWebviewAskResponse("messageResponse", "proceed")
session.awaitingInput = false
return
}
}
// Normal mode: Detect transition from processing to awaiting input
const nowAwaitingInput = pendingState.awaitingApproval || pendingState.awaitingInput
if (isProcessing && nowAwaitingInput && !wasAwaitingInput) {
// AI just finished and is now waiting for input - show prompt
setProcessingState(false)
updatePromptString().then(() => showPrompt())
}
wasAwaitingInput = nowAwaitingInput
})
// Build command context
const commandContext = {
session,
fmt: formatter,
logger,
config,
controller,
}
// Handle line input
rl.on("line", async (line: string) => {
const input = line.trim()
if (!input) {
// Empty input - just show prompt again
await updatePromptString()
showPrompt()
return
}
// Check for chat commands
if (input.startsWith("/")) {
await processSlashCommand(input, commandContext)
if (!session.isRunning) {
rl.close()
return
}
// Commands complete immediately, show prompt
await updatePromptString()
showPrompt()
return
}
// Handle approval shortcuts
if (session.awaitingApproval) {
const lowerInput = input.toLowerCase()
// Check for "don't ask again" approval (yy, yes!, or approve!)
const isAutoApprove = lowerInput === "yy" || lowerInput === "yes!" || lowerInput === "approve!"
if (isAutoApprove) {
if (controller.task && session.pendingAskMessage) {
// Determine which auto-approval action to enable
const actionKey = determineAutoApprovalAction(session.pendingAskMessage)
if (actionKey) {
// Enable auto-approval for this action type
const currentAutoApproval = controller.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const updatedActions = {
...currentAutoApproval.actions,
[actionKey]: true,
}
controller.stateManager.setTaskSettings(session.taskId!, "autoApprovalSettings", {
...currentAutoApproval,
actions: updatedActions,
})
formatter.info(`Auto-approval enabled for ${actionKey}`)
}
setProcessingState(true) // AI will start processing
wasAwaitingInput = false
await controller.task.handleWebviewAskResponse("yesButtonClicked")
session.awaitingApproval = false
session.pendingAskMessage = null
}
// Don't show prompt - wait for AI to finish
return
}
if (lowerInput === "y" || lowerInput === "yes" || lowerInput === "approve") {
if (controller.task) {
setProcessingState(true) // AI will start processing
wasAwaitingInput = false
await controller.task.handleWebviewAskResponse("yesButtonClicked")
session.awaitingApproval = false
session.pendingAskMessage = null
}
// Don't show prompt - wait for AI to finish
return
}
if (lowerInput === "n" || lowerInput === "no" || lowerInput === "deny") {
if (controller.task) {
setProcessingState(true) // AI will start processing
wasAwaitingInput = false
await controller.task.handleWebviewAskResponse("noButtonClicked")
session.awaitingApproval = false
session.pendingAskMessage = null
}
// Don't show prompt - wait for AI to finish
return
}
}
// If no active task, start a new one
if (!session.taskId) {
// Parse @path references from the input
const parsed = parseAtPaths(input, process.cwd())
// Show warnings for any files that couldn't be processed
for (const warning of parsed.warnings) {
formatter.warn(warning)
}
// Log attachment info
if (parsed.files.length > 0) {
formatter.info(`Attaching ${parsed.files.length} file(s)`)
}
if (parsed.images.length > 0) {
formatter.info(`Attaching ${parsed.images.length} image(s)`)
}
setProcessingState(true) // AI will start processing
wasAwaitingInput = false
session.taskId = await controller.initTask(
parsed.cleanedMessage,
parsed.images.length > 0 ? parsed.images : undefined,
parsed.files.length > 0 ? parsed.files : undefined,
)
formatter.info(`Started task: ${session.taskId}`)
session.adapter?.resetMessageCounter()
// Don't show prompt - wait for AI to finish
} else if (controller.task) {
// Check if input is a numbered option selection
let messageToSend = input
let imagesToSend: string[] | undefined
let filesToSend: string[] | undefined
if (session.awaitingInput && session.adapter) {
const options = session.adapter.currentOptions
const num = parseInt(input, 10)
if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
messageToSend = options[num - 1]
}
}
// Parse @path references from the input (unless it's a numbered option)
if (messageToSend === input) {
const parsed = parseAtPaths(input, process.cwd())
// Show warnings for any files that couldn't be processed
for (const warning of parsed.warnings) {
formatter.warn(warning)
}
// Log attachment info
if (parsed.files.length > 0) {
formatter.info(`Attaching ${parsed.files.length} file(s)`)
}
if (parsed.images.length > 0) {
formatter.info(`Attaching ${parsed.images.length} image(s)`)
}
messageToSend = parsed.cleanedMessage
imagesToSend = parsed.images.length > 0 ? parsed.images : undefined
filesToSend = parsed.files.length > 0 ? parsed.files : undefined
}
setProcessingState(true) // AI will start processing
wasAwaitingInput = false
// Send message to existing task with any attachments
await controller.task.handleWebviewAskResponse("messageResponse", messageToSend, imagesToSend, filesToSend)
// Don't show prompt - wait for AI to finish
}
})
// Handle close
rl.on("close", async () => {
formatter.raw("")
formatter.info("Chat session ended")
// Reset yolo mode settings if they were enabled for this session
if (session.yoloMode) {
controller.stateManager.setGlobalState("yoloModeToggled", false)
// Reset maxConsecutiveMistakes to default
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
}
// Stop listening and cleanup
session.adapter?.stopListening()
await disposeEmbeddedController(logger)
process.exit(0)
})
// Handle Ctrl+C
rl.on("SIGINT", () => {
formatter.raw("")
formatter.info("Chat session ended (interrupted)")
rl.close()
})
// Start prompt with current state (only if not already processing)
await updatePromptString()
if (!isProcessing) {
showPrompt()
}
}
/**
* Display the welcome message
*/
async function displayWelcome(formatter: OutputFormatter, session: ChatSession, controller: Controller): Promise<void> {
formatter.raw("")
formatter.info("═".repeat(60))
if (session.yoloMode) {
formatter.info(" Cline Interactive Chat Mode [YOLO]")
} else {
formatter.info(" Cline Interactive Chat Mode")
}
formatter.info("═".repeat(60))
if (session.taskId) {
formatter.info(`Task: ${session.taskId}`)
}
const state = await controller.getStateToPostToWebview()
formatter.info(`Mode: ${state.mode || "act"}`)
if (session.yoloMode) {
formatter.info("YOLO: Auto-approving all actions (5min timeout, 3 retries max)")
}
formatter.raw("")
if (!session.yoloMode) {
formatter.info("Type your message and press Enter to send.")
formatter.info("Use @path to attach files (e.g., @./file.txt, @image.png)")
formatter.info("Press Tab to toggle between act/plan mode or complete @paths.")
formatter.info("Type /help for available commands, /quit to exit.")
}
formatter.raw("─".repeat(60))
formatter.raw("")
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Chat session state management
*
* Defines the session interface and factory function.
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { CliWebviewAdapter } from "../../../core/cli-webview-adapter.js"
/**
* Chat session state
*/
export interface ChatSession {
taskId: string | null
isRunning: boolean
awaitingApproval: boolean
awaitingInput: boolean
adapter: CliWebviewAdapter | null
yoloMode: boolean
yoloFailureCount: number
yoloLastFailedAction: string | null
yoloActionStartTime: number | null
yoloCompleted: boolean
/** The current pending ask message (for determining auto-approval action type) */
pendingAskMessage: ClineMessage | null
}
/**
* Create a new chat session with default state
*/
export function createSession(yoloMode = false): ChatSession {
return {
taskId: null,
isRunning: true,
awaitingApproval: false,
awaitingInput: false,
adapter: null,
yoloMode,
yoloFailureCount: 0,
yoloLastFailedAction: null,
yoloActionStartTime: null,
yoloCompleted: false,
pendingAskMessage: null,
}
}
@@ -0,0 +1,48 @@
/**
* Checkpoints command handler - list available checkpoints
*/
import { formatCheckpointList } from "../../restore.js"
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /checkpoints command - list available checkpoints in current task
*/
export const handleCheckpoints: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
if (!ctx.controller.task) {
ctx.fmt.warn("No active task")
return true
}
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
const checkpoints = formatCheckpointList(messages)
if (checkpoints.length === 0) {
ctx.fmt.info("No checkpoints found in current task")
return true
}
ctx.fmt.info(`Checkpoints (${checkpoints.length}):\n`)
const idWidth = 16
const timeWidth = 16
const wsWidth = 12
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
ctx.fmt.raw(header)
ctx.fmt.raw("-".repeat(header.length + 30))
for (const cp of checkpoints) {
const row =
String(cp.id).padEnd(idWidth) +
cp.timeAgo.padEnd(timeWidth) +
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
cp.context
ctx.fmt.raw(row)
}
ctx.fmt.raw("")
ctx.fmt.info('Use "/restore <checkpoint-id>" to restore')
return true
}
@@ -0,0 +1,130 @@
/**
* Config command handler
*/
import fs from "fs"
import path from "path"
import { getNestedValue, parseValue, setNestedValue } from "../../../config/index.js"
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /config, /cfg commands
*/
export const handleConfig: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
const subCmd = args[0]?.toLowerCase()
const configKey = args[1]
const configValue = args.slice(2).join(" ")
if (!subCmd || subCmd === "list" || subCmd === "ls") {
// List all config values
try {
const configDir = ctx.config.configDir || `${process.env.HOME}/.cline`
const globalStatePath = path.join(configDir, "data", "globalState.json")
if (fs.existsSync(globalStatePath)) {
const content = fs.readFileSync(globalStatePath, "utf-8")
const allSettings = JSON.parse(content)
ctx.fmt.raw("")
ctx.fmt.raw(JSON.stringify(allSettings, null, 2))
ctx.fmt.raw("")
} else {
ctx.fmt.info("No configuration file found")
}
} catch (err) {
ctx.fmt.error(`Failed to list config: ${(err as Error).message}`)
}
return true
}
if (subCmd === "get") {
if (!configKey) {
ctx.fmt.error("Usage: /config get <key>")
return true
}
try {
let value: unknown
if (configKey.includes(".")) {
// For nested paths, get the root object first
const rootKey = configKey.split(".")[0]
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
if (rootValue === undefined) {
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
}
if (rootValue !== undefined && typeof rootValue === "object") {
value = getNestedValue({ [rootKey]: rootValue }, configKey)
}
} else {
value = ctx.controller.stateManager.getGlobalSettingsKey(configKey as any)
if (value === undefined) {
value = ctx.controller.stateManager.getGlobalStateKey(configKey as any)
}
}
if (value === undefined) {
ctx.fmt.info(`${configKey} is not set`)
} else {
const displayValue = typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
ctx.fmt.keyValue({ [configKey]: displayValue })
}
} catch (err) {
ctx.fmt.error(`Failed to get config: ${(err as Error).message}`)
}
return true
}
if (subCmd === "set") {
if (!configKey || !configValue) {
ctx.fmt.error("Usage: /config set <key> <value>")
return true
}
try {
const parsedValue = parseValue(configKey, configValue)
if (configKey.includes(".")) {
// For nested paths, get the current root object, modify it, and save the whole thing
const rootKey = configKey.split(".")[0]
let rootValue = ctx.controller.stateManager.getGlobalSettingsKey(rootKey as any)
if (rootValue === undefined) {
rootValue = ctx.controller.stateManager.getGlobalStateKey(rootKey as any)
}
const currentRoot = rootValue !== undefined && typeof rootValue === "object" ? rootValue : {}
const { rootValue: newRootValue } = setNestedValue({ [rootKey]: currentRoot }, configKey, parsedValue)
ctx.controller.stateManager.setGlobalState(rootKey as any, newRootValue as any)
} else {
ctx.controller.stateManager.setGlobalState(configKey as any, parsedValue as any)
}
await ctx.controller.stateManager.flushPendingState()
ctx.fmt.success(`Set ${configKey} = ${String(parsedValue)}`)
} catch (err) {
ctx.fmt.error(`Failed to set config: ${(err as Error).message}`)
}
return true
}
if (subCmd === "delete" || subCmd === "rm") {
if (!configKey) {
ctx.fmt.error("Usage: /config delete <key>")
return true
}
try {
ctx.controller.stateManager.setGlobalState(configKey as any, undefined)
await ctx.controller.stateManager.flushPendingState()
ctx.fmt.success(`Reset ${configKey} to default`)
} catch (err) {
ctx.fmt.error(`Failed to delete config: ${(err as Error).message}`)
}
return true
}
ctx.fmt.error(`Unknown config subcommand: ${subCmd}`)
ctx.fmt.raw("Usage: /config <list|get|set|delete> [key] [value]")
return true
}
@@ -0,0 +1,36 @@
/**
* Help command handler
*/
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /help, /h, /? commands
*/
export const handleHelp: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
ctx.fmt.raw("")
ctx.fmt.info("Chat commands:")
ctx.fmt.raw(" /help, /h, /? - Show this help")
ctx.fmt.raw(" /plan - Switch to plan mode")
ctx.fmt.raw(" /act - Switch to act mode")
ctx.fmt.raw(" /mode <plan|act> - Switch mode")
ctx.fmt.raw(" /model - Show current model")
ctx.fmt.raw(" /model <id> - Set model for current mode")
ctx.fmt.raw(" /model list - List available models (OpenRouter/Cline)")
ctx.fmt.raw(" /status, /s - Show task status")
ctx.fmt.raw(" /usage, /u - Show token usage and cost")
ctx.fmt.raw(" /cancel - Cancel current task")
ctx.fmt.raw(" /approve, /a, /y - Approve pending action")
ctx.fmt.raw(" /deny, /d, /n - Deny pending action")
ctx.fmt.raw(" /checkpoints, /cp - List available checkpoints")
ctx.fmt.raw(" /restore, /r <id> [type] - Restore to checkpoint")
ctx.fmt.raw(" types: task (default), workspace, taskAndWorkspace")
ctx.fmt.raw(" /config, /cfg - Manage configuration")
ctx.fmt.raw(" /config list - List all configuration values")
ctx.fmt.raw(" /config get <key> - Get a config value")
ctx.fmt.raw(" /config set <key> <value> - Set a config value")
ctx.fmt.raw(" /config delete <key> - Reset a config value")
ctx.fmt.raw(" /quit, /q, /exit - Exit chat mode")
ctx.fmt.raw("")
return true
}
@@ -0,0 +1,92 @@
/**
* Command dispatcher for chat REPL
*
* Maps command names to their handlers and dispatches incoming commands.
*/
import { handleCheckpoints } from "./checkpoints.js"
import { handleConfig } from "./config.js"
import { handleHelp } from "./help.js"
import { handleAct, handleMode, handlePlan } from "./mode.js"
import { handleModel } from "./model.js"
import { handleQuit } from "./quit.js"
import { handleRestore } from "./restore.js"
import { handleStatus } from "./status.js"
import { handleApprove, handleCancel, handleDeny } from "./task.js"
import type { CommandContext, CommandHandler } from "./types.js"
import { handleUsage } from "./usage.js"
/**
* Map of command names to their handlers
*/
const handlers: Record<string, CommandHandler> = {
// Help
help: handleHelp,
h: handleHelp,
"?": handleHelp,
// Mode
plan: handlePlan,
act: handleAct,
mode: handleMode,
m: handleMode,
// Model
model: handleModel,
// Status
status: handleStatus,
s: handleStatus,
// Task control
cancel: handleCancel,
approve: handleApprove,
a: handleApprove,
y: handleApprove,
deny: handleDeny,
d: handleDeny,
n: handleDeny,
// Config
config: handleConfig,
cfg: handleConfig,
// Usage
usage: handleUsage,
u: handleUsage,
// Quit
quit: handleQuit,
q: handleQuit,
exit: handleQuit,
// Checkpoints
checkpoints: handleCheckpoints,
cp: handleCheckpoints,
restore: handleRestore,
r: handleRestore,
}
/**
* Process a chat command (input starting with /)
*
* @param input - Full command input including the leading /
* @param ctx - Command context
* @returns true if the command was handled
*/
export async function processSlashCommand(input: string, ctx: CommandContext): Promise<boolean> {
const parts = input.slice(1).split(/\s+/)
const cmd = parts[0].toLowerCase()
const args = parts.slice(1)
const handler = handlers[cmd]
if (!handler) {
ctx.fmt.warn(`Unknown command: /${cmd}. Type /help for available commands.`)
return true
}
return handler(args, ctx)
}
// Re-export types for convenience
export type { CommandContext, CommandHandler } from "./types.js"
@@ -0,0 +1,42 @@
/**
* Mode command handlers
*/
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /plan command
*/
export const handlePlan: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
await ctx.controller.togglePlanActMode("plan")
ctx.fmt.success("Switched to plan mode")
return true
}
/**
* Handle /act command
*/
export const handleAct: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
await ctx.controller.togglePlanActMode("act")
ctx.fmt.success("Switched to act mode")
return true
}
/**
* Handle /mode command
*/
export const handleMode: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
if (args.length === 0) {
const state = await ctx.controller.getStateToPostToWebview()
ctx.fmt.info(`Current mode: ${state.mode || "unknown"}`)
} else {
const newMode = args[0].toLowerCase()
if (newMode !== "plan" && newMode !== "act") {
ctx.fmt.error("Invalid mode. Use 'plan' or 'act'")
} else {
await ctx.controller.togglePlanActMode(newMode as "plan" | "act")
ctx.fmt.success(`Switched to ${newMode} mode`)
}
}
return true
}
@@ -0,0 +1,94 @@
/**
* Model command handler
*/
import type { Mode } from "@shared/storage/types"
import { getModelIdForProvider, getModelIdKey } from "../model-utils.js"
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /model command
*/
export const handleModel: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
const state = await ctx.controller.getStateToPostToWebview()
const currentMode: Mode = (state.mode as Mode) || "act"
const apiConfig = state.apiConfiguration
// Get current provider for this mode
const provider = currentMode === "plan" ? apiConfig?.planModeApiProvider : apiConfig?.actModeApiProvider
const subCmd = args[0]?.toLowerCase()
if (!subCmd) {
// Show current model
const modelId = getModelIdForProvider(apiConfig, provider, currentMode)
ctx.fmt.raw("")
ctx.fmt.info(`Mode: ${currentMode}`)
ctx.fmt.info(`Provider: ${provider || "(not set)"}`)
ctx.fmt.info(`Model: ${modelId || "(not set)"}`)
ctx.fmt.raw("")
return true
}
if (subCmd === "list") {
// Fetch models from OpenRouter if applicable
if (provider === "openrouter" || provider === "cline") {
ctx.fmt.info("Fetching models from OpenRouter...")
try {
const response = await fetch("https://openrouter.ai/api/v1/models")
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = (await response.json()) as {
data?: Array<{
id: string
name?: string
pricing?: { prompt?: string; completion?: string }
}>
}
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id))
ctx.fmt.raw("")
ctx.fmt.info(`Available models (${models.length} total):`)
ctx.fmt.raw("")
// Show all models with pricing info (alphabetized)
for (const model of models) {
const promptPrice = model.pricing?.prompt
? `$${(parseFloat(model.pricing.prompt) * 1_000_000).toFixed(2)}/M`
: "N/A"
const completionPrice = model.pricing?.completion
? `$${(parseFloat(model.pricing.completion) * 1_000_000).toFixed(2)}/M`
: "N/A"
ctx.fmt.raw(` ${model.id}`)
ctx.fmt.raw(` Input: ${promptPrice}, Output: ${completionPrice}`)
}
ctx.fmt.raw("")
ctx.fmt.info("Use '/model <model-id>' to set the model")
ctx.fmt.raw("")
} catch (err) {
ctx.fmt.error(`Failed to fetch models: ${(err as Error).message}`)
}
} else {
ctx.fmt.warn(`Model listing not available for provider: ${provider || "none"}`)
ctx.fmt.info("Model listing is only supported for OpenRouter and Cline providers.")
}
return true
}
// Set model - args is the model ID (may contain slashes like "anthropic/claude-3")
const newModelId = args.join(" ")
if (!provider) {
ctx.fmt.error("No provider configured for current mode.")
ctx.fmt.info("Run 'cline auth' to configure a provider first.")
return true
}
const modelIdKey = getModelIdKey(provider, currentMode)
ctx.controller.stateManager.setGlobalState(modelIdKey as any, newModelId)
await ctx.controller.stateManager.flushPendingState()
ctx.fmt.success(`Set ${currentMode} mode model to: ${newModelId}`)
return true
}
@@ -0,0 +1,13 @@
/**
* Quit command handler
*/
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /quit, /q, /exit commands
*/
export const handleQuit: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
ctx.session.isRunning = false
return true
}
@@ -0,0 +1,144 @@
/**
* Restore command handler - restore task to a checkpoint
*/
import { validateCheckpoint } from "../../restore.js"
import { handleCheckpoints } from "./checkpoints.js"
import type { CommandContext, CommandHandler } from "./types.js"
/** Valid restore types */
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
/**
* Get relative time string (e.g., "2 hours ago")
*/
function getTimeAgo(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return days === 1 ? "1 day ago" : `${days} days ago`
}
if (hours > 0) {
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
}
if (minutes > 0) {
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
}
return "just now"
}
/**
* Handle /restore command - restore task to a checkpoint
*
* Usage:
* /restore <checkpoint-id> [type]
* /restore list - List available checkpoints (alias for /checkpoints)
*
* Types:
* task - Restore conversation only (default)
* workspace - Restore files only
* taskAndWorkspace - Restore both
*/
export const handleRestore: CommandHandler = async (args: string[], ctx: CommandContext): Promise<boolean> => {
// Handle "list" subcommand
if (args[0] === "list" || args[0] === "ls") {
return handleCheckpoints(args.slice(1), ctx)
}
// Check for active task
if (!ctx.controller.task) {
ctx.fmt.warn("No active task")
return true
}
// Validate arguments
if (args.length === 0) {
ctx.fmt.warn("Usage: /restore <checkpoint-id> [type]")
ctx.fmt.info(" checkpoint-id: The timestamp ID of the checkpoint")
ctx.fmt.info(" type: task (default), workspace, or taskAndWorkspace")
ctx.fmt.info("")
ctx.fmt.info('Use "/checkpoints" or "/restore list" to see available checkpoints')
return true
}
// Parse checkpoint ID
const checkpointIdArg = args[0]
const checkpointId = parseInt(checkpointIdArg, 10)
if (isNaN(checkpointId)) {
ctx.fmt.error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
return true
}
// Parse restore type (default: task)
let restoreType: RestoreType = "task"
if (args[1]) {
const providedType = args[1].toLowerCase()
if (!VALID_RESTORE_TYPES.includes(providedType as RestoreType)) {
ctx.fmt.error(`Invalid restore type: "${args[1]}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
return true
}
restoreType = providedType as RestoreType
}
// Get messages and validate checkpoint exists
const messages = ctx.controller.task.messageStateHandler.getClineMessages()
const checkpoint = validateCheckpoint(messages, checkpointId)
if (!checkpoint) {
// Check if the timestamp exists but is not a checkpoint
const anyMessage = messages.find((m) => m.ts === checkpointId)
if (anyMessage) {
ctx.fmt.error(`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`)
} else {
ctx.fmt.error(`Checkpoint ${checkpointId} not found in task history`)
}
ctx.fmt.info('Use "/checkpoints" to see available checkpoints')
return true
}
// Check if workspace restore is possible
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
ctx.fmt.warn("Warning: This checkpoint does not have workspace restore data.")
if (restoreType === "workspace") {
ctx.fmt.error("Cannot restore workspace: no checkpoint hash available")
return true
}
ctx.fmt.info("Falling back to task-only restore.")
restoreType = "task"
}
// Perform the restore
ctx.fmt.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
ctx.fmt.info(`Restore type: ${restoreType}`)
try {
// Cancel any active task first (required before restore)
await ctx.controller.cancelTask()
// Call restoreCheckpoint on the checkpoint manager
const checkpointManager = ctx.controller.task?.checkpointManager
if (!checkpointManager) {
ctx.fmt.error("Checkpoint manager not available")
return true
}
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
ctx.fmt.success("Checkpoint restored successfully")
// Show post-restore state
const newMessages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
ctx.fmt.info(`Task now has ${newMessages.length} messages`)
} catch (error) {
ctx.fmt.error(`Failed to restore checkpoint: ${(error as Error).message}`)
}
return true
}
@@ -0,0 +1,24 @@
/**
* Status command handler
*/
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /status, /s commands
*/
export const handleStatus: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
const state = await ctx.controller.getStateToPostToWebview()
ctx.fmt.raw("")
ctx.fmt.info(`Task ID: ${ctx.session.taskId || "none"}`)
ctx.fmt.info(`Mode: ${state.mode || "unknown"}`)
ctx.fmt.info(`Messages: ${state.clineMessages?.length || 0}`)
if (ctx.session.awaitingApproval) {
ctx.fmt.warn("Awaiting approval (use /approve or /deny)")
}
if (ctx.session.awaitingInput) {
ctx.fmt.warn("Awaiting user input")
}
ctx.fmt.raw("")
return true
}
@@ -0,0 +1,46 @@
/**
* Task-related command handlers (cancel, approve, deny)
*/
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Handle /cancel command
*/
export const handleCancel: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
if (ctx.controller.task) {
await ctx.controller.cancelTask()
ctx.fmt.success("Task cancelled")
} else {
ctx.fmt.warn("No active task to cancel")
}
return true
}
/**
* Handle /approve, /a, /y commands
*/
export const handleApprove: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
if (!ctx.session.awaitingApproval) {
ctx.fmt.warn("No pending approval request")
} else if (ctx.controller.task) {
await ctx.controller.task.handleWebviewAskResponse("yesButtonClicked")
ctx.session.awaitingApproval = false
ctx.fmt.success("Action approved")
}
return true
}
/**
* Handle /deny, /d, /n commands
*/
export const handleDeny: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
if (!ctx.session.awaitingApproval) {
ctx.fmt.warn("No pending approval request")
} else if (ctx.controller.task) {
await ctx.controller.task.handleWebviewAskResponse("noButtonClicked")
ctx.session.awaitingApproval = false
ctx.fmt.success("Action denied")
}
return true
}
@@ -0,0 +1,28 @@
/**
* Types for chat command handlers
*/
import type { Controller } from "@/core/controller"
import type { OutputFormatter } from "../../../../core/output/types.js"
import type { CliConfig } from "../../../../types/config.js"
import type { Logger } from "../../../../types/logger.js"
import type { ChatSession } from "../session.js"
/**
* Context passed to all command handlers
*/
export interface CommandContext {
session: ChatSession
fmt: OutputFormatter
logger: Logger
config: CliConfig
controller: Controller
}
/**
* Handler function for a chat command
* @param args - Arguments after the command name
* @param ctx - Command context with session, formatter, etc.
* @returns true if the command was handled (input should not be passed to AI)
*/
export type CommandHandler = (args: string[], ctx: CommandContext) => Promise<boolean>
@@ -0,0 +1,60 @@
/**
* Usage command handler
*
* Displays token usage and cost for the current conversation
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import type { CommandContext, CommandHandler } from "./types.js"
/**
* Count API requests from messages
*/
function countApiRequests(messages: ClineMessage[]): number {
return messages.filter((msg) => msg.type === "say" && msg.say === "api_req_started").length
}
/**
* Format number with commas
*/
function formatNumber(n: number): string {
return n.toLocaleString()
}
/**
* Handle /usage, /u commands
*/
export const handleUsage: CommandHandler = async (_args: string[], ctx: CommandContext): Promise<boolean> => {
// Get messages from the current session
const messages = ctx.controller.task?.messageStateHandler.getClineMessages() || []
if (messages.length === 0) {
ctx.fmt.warn("No messages in current conversation")
return true
}
const metrics = getApiMetrics(messages)
const requestCount = countApiRequests(messages)
ctx.fmt.raw("")
ctx.fmt.info("📊 Token Usage & Cost")
ctx.fmt.raw("")
ctx.fmt.raw(` Input tokens: ${formatNumber(metrics.totalTokensIn)}`)
ctx.fmt.raw(` Output tokens: ${formatNumber(metrics.totalTokensOut)}`)
ctx.fmt.raw(` Total tokens: ${formatNumber(metrics.totalTokensIn + metrics.totalTokensOut)}`)
// Show cache metrics if available
if (metrics.totalCacheWrites !== undefined || metrics.totalCacheReads !== undefined) {
ctx.fmt.raw("")
ctx.fmt.raw(` Cache writes: ${formatNumber(metrics.totalCacheWrites ?? 0)}`)
ctx.fmt.raw(` Cache reads: ${formatNumber(metrics.totalCacheReads ?? 0)}`)
}
ctx.fmt.raw("")
ctx.fmt.raw(` API requests: ${requestCount}`)
ctx.fmt.raw(` Total cost: $${metrics.totalCost.toFixed(4)}`)
ctx.fmt.raw("")
return true
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Task dump command - output raw JSON of conversation messages
*
* This command outputs the raw JSON of a task's ClineMessages array,
* useful for debugging or external processing.
*/
import { getSavedClineMessages, readTaskHistoryFromState } from "@core/storage/disk"
import { Command } from "commander"
import { initializeHostProviderOnly } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
/**
* Create the task dump command
*/
export function createTaskDumpCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const dumpCommand = new Command("dump")
.alias("d")
.description("Output raw JSON of task conversation messages")
.argument("[taskId]", "Task ID to dump (defaults to current or most recent task)")
.action(async (taskIdArg: string | undefined) => {
logger.debug("Task dump command called", { taskIdArg })
try {
// Initialize HostProvider only (lightweight, no full controller)
initializeHostProviderOnly(logger, config.configDir)
// Read task history directly from disk
const taskHistory = await readTaskHistoryFromState()
// Determine which task to dump
let taskId = taskIdArg
if (taskId) {
// Find task by ID (support partial ID match)
const historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
if (!historyItem) {
throw new Error(`Task not found: ${taskId}`)
}
taskId = historyItem.id
} else {
// Use most recent task
if (taskHistory.length > 0) {
taskId = taskHistory[0].id
} else {
throw new Error("No tasks found. Create a task with 'cline task new'")
}
}
// Read messages directly from disk storage
const messages = await getSavedClineMessages(taskId)
formatter.raw(JSON.stringify(messages, null, 2))
} catch (error) {
formatter.error((error as Error).message)
process.exit(1)
}
})
return dumpCommand
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Task command group - manage Cline tasks
*/
import { Command } from "commander"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
import { createTaskChatCommand } from "./chat/index.js"
import { createTaskDumpCommand } from "./dump.js"
import { createTaskListCommand } from "./list.js"
import { createTaskRestoreCommand } from "./restore.js"
import { createTaskSendCommand } from "./send.js"
import { createTaskViewCommand } from "./view.js"
/**
* Create the task command group
*/
export function createTaskCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const taskCommand = new Command("task").alias("t").description("Manage Cline tasks")
// Add subcommands
taskCommand.addCommand(createTaskListCommand(config, logger, formatter))
taskCommand.addCommand(createTaskChatCommand(config, logger, formatter))
taskCommand.addCommand(createTaskSendCommand(config, logger, formatter))
taskCommand.addCommand(createTaskViewCommand(config, logger, formatter))
taskCommand.addCommand(createTaskRestoreCommand(config, logger, formatter))
taskCommand.addCommand(createTaskDumpCommand(config, logger, formatter))
return taskCommand
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Task list command - list task history
*
* This command uses Cline's EmbeddedController to read task history directly,
* ensuring CLI task list matches what the extension shows.
*/
import { Command } from "commander"
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
/**
* Get relative time string (e.g., "2 hours ago")
*/
function getTimeAgo(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const weeks = Math.floor(days / 7)
const months = Math.floor(days / 30)
if (months > 0) {
return months === 1 ? "1 month ago" : `${months} months ago`
}
if (weeks > 0) {
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`
}
if (days > 0) {
return days === 1 ? "1 day ago" : `${days} days ago`
}
if (hours > 0) {
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
}
if (minutes > 0) {
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
}
return "just now"
}
/**
* Truncate a string to a maximum length with ellipsis
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return str.slice(0, maxLength - 3) + "..."
}
/**
* Format cost as a currency string
*/
function formatCost(cost: number): string {
if (cost === 0) {
return "$0.00"
}
if (cost < 0.01) {
return `$${cost.toFixed(4)}`
}
return `$${cost.toFixed(2)}`
}
/**
* Create the task list command
*/
export function createTaskListCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const listCommand = new Command("list")
.alias("l")
.alias("ls")
.description("List task history")
.option("-n, --limit <number>", "Maximum number of tasks to show", "20")
.option("-a, --all", "Show all tasks (no limit)", false)
.action(async (options) => {
logger.debug("Task list command called", { options })
try {
// Initialize embedded controller to access task history
const controller = await getEmbeddedController(logger, config.configDir)
// Get task history from the state
const state = await controller.getStateToPostToWebview()
let tasks = state.taskHistory || []
// Parse limit
const limit = options.all ? undefined : parseInt(options.limit, 10)
if (limit !== undefined && (Number.isNaN(limit) || limit < 1)) {
formatter.error("Invalid limit value")
await disposeEmbeddedController(logger)
process.exit(1)
}
// Apply limit if specified
if (limit !== undefined) {
tasks = tasks.slice(0, limit)
}
logger.debug(`Found ${tasks.length} tasks`)
// Handle empty list
if (tasks.length === 0) {
formatter.info("No tasks found")
if (config.outputFormat === "json") {
formatter.raw("[]")
}
await disposeEmbeddedController(logger)
process.exit(0)
return
}
// Output based on format
if (config.outputFormat === "json") {
// JSON output: full task info
formatter.raw(JSON.stringify(tasks, null, 2))
} else {
// Rich/plain output: formatted table
formatter.info(`Task History (${tasks.length} task${tasks.length === 1 ? "" : "s"}):\n`)
// Calculate column widths for alignment
const idWidth = 15
const timeWidth = 16
const costWidth = 10
const modelWidth = 20
// Header
const header =
"ID".padEnd(idWidth) +
"Time".padEnd(timeWidth) +
"Cost".padEnd(costWidth) +
"Model".padEnd(modelWidth) +
"Prompt"
formatter.raw(header)
formatter.raw("-".repeat(header.length + 20))
// Rows
for (const task of tasks) {
const row =
task.id.padEnd(idWidth) +
getTimeAgo(task.ts).padEnd(timeWidth) +
formatCost(task.totalCost).padEnd(costWidth) +
truncate(task.modelId || "unknown", modelWidth - 2).padEnd(modelWidth) +
truncate(task.task.replace(/\n/g, " "), 50)
formatter.raw(row)
}
formatter.raw("")
formatter.info('Use "cline task open <id>" to resume a task')
}
// Cleanup and exit
await disposeEmbeddedController(logger)
process.exit(0)
} catch (error) {
formatter.error((error as Error).message)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
return listCommand
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Task restore command - restore a task to a specific checkpoint
*
* This command restores a task to a previous checkpoint, optionally
* restoring both the conversation state and workspace files.
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Command } from "commander"
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
/** Valid restore types */
type RestoreType = "task" | "workspace" | "taskAndWorkspace"
const VALID_RESTORE_TYPES: RestoreType[] = ["task", "workspace", "taskAndWorkspace"]
/**
* Get relative time string (e.g., "2 hours ago")
*/
function getTimeAgo(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return days === 1 ? "1 day ago" : `${days} days ago`
}
if (hours > 0) {
return hours === 1 ? "1 hour ago" : `${hours} hours ago`
}
if (minutes > 0) {
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`
}
return "just now"
}
/**
* Truncate a string to a maximum length with ellipsis
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) {
return str
}
return str.slice(0, maxLength - 3) + "..."
}
/**
* Find checkpoints in a list of messages
*/
export function findCheckpoints(messages: ClineMessage[]): ClineMessage[] {
return messages.filter((m) => m.say === "checkpoint_created")
}
/**
* Validate that a checkpoint ID exists in the messages
*/
export function validateCheckpoint(messages: ClineMessage[], checkpointId: number): ClineMessage | null {
return messages.find((m) => m.ts === checkpointId && m.say === "checkpoint_created") || null
}
/**
* Get context for a checkpoint (the preceding user message)
*/
function getCheckpointContext(messages: ClineMessage[], checkpointIndex: number): string {
// Look backwards for the most recent user message
for (let i = checkpointIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.type === "say" && msg.say === "text" && msg.text) {
return truncate(msg.text.replace(/\n/g, " "), 50)
}
if (msg.type === "ask" && msg.text) {
return truncate(msg.text.replace(/\n/g, " "), 50)
}
}
return "(no context)"
}
/**
* Format checkpoints for display
*/
export function formatCheckpointList(messages: ClineMessage[]): Array<{
id: number
timeAgo: string
context: string
hasWorkspaceRestore: boolean
}> {
const checkpoints = findCheckpoints(messages)
return checkpoints.map((cp) => {
const index = messages.indexOf(cp)
return {
id: cp.ts,
timeAgo: getTimeAgo(cp.ts),
context: getCheckpointContext(messages, index),
hasWorkspaceRestore: !!cp.lastCheckpointHash,
}
})
}
/**
* Create the task restore command
*/
export function createTaskRestoreCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const restoreCommand = new Command("restore")
.alias("r")
.description("Restore task to a specific checkpoint")
.argument("<checkpoint-id>", "Checkpoint ID (timestamp) to restore to")
.option(
"-t, --type <type>",
"Restore type: task (conversation only), workspace (files only), taskAndWorkspace (both)",
"task",
)
.option("-l, --list", "List available checkpoints instead of restoring", false)
.action(async (checkpointIdArg: string, options) => {
logger.debug("Task restore command called", { checkpointIdArg, options })
try {
// Initialize embedded controller
const controller = await getEmbeddedController(logger, config.configDir)
// Check if there's an active task
if (!controller.task) {
// Try to get the most recent task
const state = await controller.getStateToPostToWebview()
const taskHistory = state.taskHistory || []
if (taskHistory.length === 0) {
throw new Error("No tasks found. Create a task first.")
}
// Initialize the most recent task
const historyItem = taskHistory[0]
const taskData = await controller.getTaskWithId(historyItem.id)
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
}
// Get messages from the task
const messages = controller.task?.messageStateHandler.getClineMessages() || []
if (messages.length === 0) {
throw new Error("No messages in current task")
}
// Handle --list option
if (options.list) {
const checkpoints = formatCheckpointList(messages)
if (checkpoints.length === 0) {
formatter.info("No checkpoints found in current task")
await disposeEmbeddedController(logger)
return
}
if (config.outputFormat === "json") {
formatter.raw(JSON.stringify(checkpoints, null, 2))
} else {
formatter.info(`Checkpoints (${checkpoints.length}):\n`)
const idWidth = 16
const timeWidth = 16
const wsWidth = 12
const header = "ID".padEnd(idWidth) + "Time".padEnd(timeWidth) + "Workspace".padEnd(wsWidth) + "Context"
formatter.raw(header)
formatter.raw("-".repeat(header.length + 30))
for (const cp of checkpoints) {
const row =
String(cp.id).padEnd(idWidth) +
cp.timeAgo.padEnd(timeWidth) +
(cp.hasWorkspaceRestore ? "Yes" : "No").padEnd(wsWidth) +
cp.context
formatter.raw(row)
}
formatter.raw("")
formatter.info('Use "cline task restore <checkpoint-id>" to restore')
}
await disposeEmbeddedController(logger)
return
}
// Parse and validate checkpoint ID
const checkpointId = parseInt(checkpointIdArg, 10)
if (isNaN(checkpointId)) {
throw new Error(`Invalid checkpoint ID: "${checkpointIdArg}". Must be a number (timestamp).`)
}
// Validate restore type
const restoreType = options.type as RestoreType
if (!VALID_RESTORE_TYPES.includes(restoreType)) {
throw new Error(`Invalid restore type: "${restoreType}". Valid options: ${VALID_RESTORE_TYPES.join(", ")}`)
}
// Validate checkpoint exists
const checkpoint = validateCheckpoint(messages, checkpointId)
if (!checkpoint) {
// Check if the timestamp exists but is not a checkpoint
const anyMessage = messages.find((m) => m.ts === checkpointId)
if (anyMessage) {
throw new Error(
`Timestamp ${checkpointId} exists but is not a checkpoint (type: ${anyMessage.say || anyMessage.ask})`,
)
}
throw new Error(`Checkpoint ${checkpointId} not found in task history`)
}
// Check if workspace restore is possible
if ((restoreType === "workspace" || restoreType === "taskAndWorkspace") && !checkpoint.lastCheckpointHash) {
formatter.warn("Warning: This checkpoint does not have workspace restore data.")
if (restoreType === "workspace") {
throw new Error("Cannot restore workspace: no checkpoint hash available")
}
formatter.info("Falling back to task-only restore.")
}
// Perform the restore
formatter.info(`Restoring to checkpoint ${checkpointId} (${getTimeAgo(checkpointId)})...`)
formatter.info(`Restore type: ${restoreType}`)
// Cancel any active task first (required before restore)
await controller.cancelTask()
// Call restoreCheckpoint on the checkpoint manager
const checkpointManager = controller.task?.checkpointManager
if (!checkpointManager) {
throw new Error("Checkpoint manager not available")
}
await checkpointManager.restoreCheckpoint(checkpointId, restoreType)
formatter.success("Checkpoint restored successfully")
// Show post-restore state
const newMessages = controller.task?.messageStateHandler.getClineMessages() || []
formatter.info(`Task now has ${newMessages.length} messages`)
await disposeEmbeddedController(logger)
} catch (error) {
formatter.error((error as Error).message)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
return restoreCommand
}
+474
View File
@@ -0,0 +1,474 @@
/**
* Task send command - send a message to the current task using embedded Controller
*
* This command sends a single message to an active task using Cline's
* embedded Controller, allowing non-interactive AI interactions.
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Command } from "commander"
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
import { disposeEmbeddedController, getControllerIfInitialized, getEmbeddedController } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import { parseAtPaths, processExplicitFiles, processExplicitImages } from "../../core/path-parser.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
import { checkForPendingInput, isCompletionState, isFailureState } from "./chat/input-checker.js"
/** Yolo mode timeout: 5 minutes in milliseconds */
const YOLO_TIMEOUT_MS = 5 * 60 * 1000
/** Yolo mode max consecutive failures before abort */
const YOLO_MAX_FAILURES = 3
/**
* Validate mode option
*/
function validateMode(mode: string | undefined): "act" | "plan" | undefined {
if (!mode) {
return undefined
}
if (mode !== "act" && mode !== "plan") {
throw new Error(`Invalid mode: "${mode}". Valid options are: act, plan`)
}
return mode
}
/**
* Read input from stdin if available
*/
async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (interactive terminal)
if (process.stdin.isTTY) {
return null
}
return new Promise((resolve) => {
let data = ""
process.stdin.setEncoding("utf-8")
process.stdin.on("readable", () => {
let chunk: string | null
while ((chunk = process.stdin.read() as string | null) !== null) {
data += chunk
}
})
process.stdin.on("end", () => {
resolve(data.trim() || null)
})
// Timeout after 100ms if no data
setTimeout(() => {
if (!data) {
resolve(null)
}
}, 100)
})
}
/**
* Check if the last message requires user input
*/
function isAwaitingResponse(messages: ClineMessage[]): boolean {
if (messages.length === 0) {
return false
}
const lastMessage = messages[messages.length - 1]
// Skip partial messages
if (lastMessage.partial) {
return false
}
// Check if this is an "ask" type message
return lastMessage.type === "ask"
}
/**
* Yolo mode state for tracking failures
*/
interface YoloState {
failureCount: number
lastFailedAction: string | null
actionStartTime: number
completed: boolean
}
/**
* Wait for task to reach a stopping point (either completion or awaiting input)
* In yolo mode, auto-approves actions and continues until completion
*/
async function waitForTaskResponse(
controller: Awaited<ReturnType<typeof getEmbeddedController>>,
formatter: OutputFormatter,
timeoutMs = 300000, // 5 minutes default timeout
yoloMode = false,
): Promise<void> {
const adapter = new CliWebviewAdapter(controller, formatter)
adapter.startListening()
const yoloState: YoloState = {
failureCount: 0,
lastFailedAction: null,
actionStartTime: Date.now(),
completed: false,
}
return new Promise((resolve, reject) => {
const startTime = Date.now()
const checkInterval = setInterval(async () => {
const messages = adapter.getMessages()
// YOLO MODE: Auto-respond and continue until completion
if (yoloMode && controller.task && !yoloState.completed) {
// Check for task completion first
if (isCompletionState(messages)) {
// Guard against processing completion multiple times
yoloState.completed = true
formatter.success("\n[YOLO] Task completed!")
clearInterval(checkInterval)
adapter.stopListening()
// Respond to the completion_result ask to unblock the handler
const task = controller.task
await task.handleWebviewAskResponse("yesButtonClicked")
// Give time for the response to be fully processed
await new Promise((r) => setTimeout(r, 200))
// Abort the task to stop the loop - this is expected after completion
try {
await task.abortTask()
} catch {
// Task may already be cleaned up, ignore
}
// Exit successfully - don't wait for full cleanup in yolo mode
// The task has completed successfully, so exit code 0
process.exit(0)
}
// Check for yolo timeout (5 minutes per action)
if (Date.now() - yoloState.actionStartTime > YOLO_TIMEOUT_MS) {
clearInterval(checkInterval)
adapter.stopListening()
reject(new Error("[YOLO] Action timed out after 5 minutes"))
return
}
// Check for failure state
const failureCheck = isFailureState(messages)
if (failureCheck.isFailure) {
if (yoloState.lastFailedAction === failureCheck.actionKey) {
yoloState.failureCount++
} else {
yoloState.lastFailedAction = failureCheck.actionKey
yoloState.failureCount = 1
}
if (yoloState.failureCount >= YOLO_MAX_FAILURES) {
clearInterval(checkInterval)
adapter.stopListening()
reject(new Error(`[YOLO] Same action failed ${YOLO_MAX_FAILURES} times`))
return
}
// Auto-retry
formatter.warn(`[YOLO] Action failed (attempt ${yoloState.failureCount}/${YOLO_MAX_FAILURES}), retrying...`)
yoloState.actionStartTime = Date.now()
await controller.task.handleWebviewAskResponse("yesButtonClicked")
return
} else if (failureCheck.actionKey === null) {
// Reset failure tracking on non-failure state
yoloState.failureCount = 0
yoloState.lastFailedAction = null
}
// Check for pending input and auto-respond
const pendingState = checkForPendingInput(messages)
if (pendingState.awaitingApproval) {
yoloState.actionStartTime = Date.now()
await controller.task.handleWebviewAskResponse("yesButtonClicked")
return
}
if (pendingState.awaitingInput) {
yoloState.actionStartTime = Date.now()
await controller.task.handleWebviewAskResponse("messageResponse", "proceed")
return
}
// Continue waiting for next state
return
}
// Normal mode: Check if task completed or awaiting response
if (isAwaitingResponse(messages)) {
clearInterval(checkInterval)
adapter.stopListening()
resolve()
return
}
// Check for task completion (no task or task finished)
if (!controller.task) {
clearInterval(checkInterval)
adapter.stopListening()
resolve()
return
}
// Check timeout
if (Date.now() - startTime > timeoutMs) {
clearInterval(checkInterval)
adapter.stopListening()
reject(new Error("Task timed out waiting for response"))
}
}, 100)
})
}
/**
* Collect multiple option values into an array
* Used for -f and -i options that can be specified multiple times
*/
function collectOption(value: string, previous: string[]): string[] {
return previous.concat([value])
}
/**
* Create the task send command
*/
export function createTaskSendCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const sendCommand = new Command("send")
.alias("s")
.description("Send a message to the current task using embedded Controller")
.argument("[message]", "Message to send (reads from stdin if not provided)")
.option("-t, --task <id>", "Target task ID (starts new task if not specified)")
.option("-a, --approve", "Approve a proposed action", false)
.option("-d, --deny", "Deny a proposed action", false)
.option("-f, --file <path>", "Attach file to message (can be repeated)", collectOption, [])
.option("-i, --image <path>", "Attach image to message (can be repeated)", collectOption, [])
.option("-y, --yolo", "Enable autonomous mode (no confirmations)", false)
.option("--no-interactive", "Same as --yolo")
.option("-m, --mode <mode>", "Switch to mode: act or plan")
.option("-w, --wait", "Wait for task to complete or await input", false)
.option("--timeout <ms>", "Timeout in milliseconds when using --wait (default: 300000)")
.action(async (messageArg: string | undefined, options) => {
logger.debug("Task send command called", { messageArg, options })
try {
// Validate mutual exclusivity of approve/deny
if (options.approve && options.deny) {
throw new Error("Cannot use both --approve and --deny options")
}
// Validate mode if provided
const mode = validateMode(options.mode)
// Process explicit file and image attachments from CLI options
const cwd = process.cwd()
let explicitFiles: string[] = []
let explicitImages: string[] = []
// Process -f/--file options (can be files or images, auto-detected)
if (options.file && options.file.length > 0) {
const processed = processExplicitFiles(options.file, cwd)
explicitFiles = processed.files
explicitImages = processed.images
}
// Process -i/--image options (must be images)
if (options.image && options.image.length > 0) {
const images = processExplicitImages(options.image, cwd)
explicitImages = explicitImages.concat(images)
}
// Initialize embedded controller
const controller = await getEmbeddedController(logger, config.configDir)
// Handle mode switch
if (mode) {
await controller.togglePlanActMode(mode)
formatter.info(`Switched to ${mode} mode`)
}
// Set up YOLO mode in Cline core settings if --yolo flag is set
// This enables the core to:
// 1. Modify system prompt to not ask followup questions
// 2. Auto-switch from Plan to Act mode
// 3. Auto-approve tools based on auto-approval settings
if (options.yolo) {
controller.stateManager.setGlobalState("yoloModeToggled", true)
// Increase mistake limit for autonomous operation (matches Go CLI behavior)
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 6)
// Ensure we're in Act mode for autonomous execution (unless user explicitly chose a mode)
if (!mode) {
await controller.togglePlanActMode("act")
}
}
// Handle approve/deny for existing task
if (options.approve || options.deny) {
if (!controller.task) {
throw new Error("No active task to approve/deny")
}
const response = options.approve ? "yesButtonClicked" : "noButtonClicked"
await controller.task.handleWebviewAskResponse(response)
formatter.success(options.approve ? "Action approved" : "Action denied")
if (options.wait || options.yolo) {
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
}
// Output result in JSON format if requested
if (config.outputFormat === "json") {
const state = await controller.getStateToPostToWebview()
formatter.raw(
JSON.stringify(
{
taskId: controller.task?.taskId,
action: options.approve ? "approved" : "denied",
messageCount: state.clineMessages?.length || 0,
},
null,
2,
),
)
}
await disposeEmbeddedController(logger)
return
}
// Determine message content
let message = messageArg
// Try to read from stdin if no message argument
if (!message) {
const stdinMessage = await readStdin()
if (stdinMessage) {
message = stdinMessage
}
}
if (!message) {
throw new Error("No message provided. Use argument or pipe via stdin")
}
// Parse @path references from the message
const parsedPaths = parseAtPaths(message, cwd)
// Show warnings for any files that couldn't be processed (non-fatal for @paths)
for (const warning of parsedPaths.warnings) {
formatter.warn(warning)
}
// Use cleaned message (with @paths removed)
const cleanedMessage = parsedPaths.cleanedMessage
// Combine explicit attachments with @path attachments
const allFiles = [...explicitFiles, ...parsedPaths.files]
const allImages = [...explicitImages, ...parsedPaths.images]
// Log attachment info
if (allFiles.length > 0) {
formatter.info(`Attaching ${allFiles.length} file(s)`)
}
if (allImages.length > 0) {
formatter.info(`Attaching ${allImages.length} image(s)`)
}
// Start or continue task
let taskId: string | undefined
if (options.task) {
// Resume existing task
const history = await controller.getTaskWithId(options.task)
if (!history) {
throw new Error(`Task not found: ${options.task}`)
}
taskId = await controller.initTask(undefined, undefined, undefined, history.historyItem)
formatter.info(`Resumed task: ${taskId}`)
// Send the message with attachments
if (controller.task) {
await controller.task.handleWebviewAskResponse(
"messageResponse",
cleanedMessage,
allImages.length > 0 ? allImages : undefined,
allFiles.length > 0 ? allFiles : undefined,
)
}
} else if (controller.task) {
// Send to existing active task
taskId = controller.task.taskId
await controller.task.handleWebviewAskResponse(
"messageResponse",
cleanedMessage,
allImages.length > 0 ? allImages : undefined,
allFiles.length > 0 ? allFiles : undefined,
)
formatter.info(`Message sent to task ${taskId.slice(0, 8)}`)
} else {
// Start new task with the message as prompt
taskId = await controller.initTask(
cleanedMessage,
allImages.length > 0 ? allImages : undefined,
allFiles.length > 0 ? allFiles : undefined,
)
formatter.info(`Started new task: ${taskId}`)
}
// Wait for response if requested (yolo mode always waits for completion)
if (options.wait || options.yolo) {
if (options.yolo) {
formatter.info("[YOLO] Autonomous mode - running until completion...")
} else {
formatter.info("Waiting for task response...")
}
await waitForTaskResponse(controller, formatter, parseInt(options.timeout) || 300000, options.yolo)
}
// Output result in JSON format if requested
if (config.outputFormat === "json") {
const state = await controller.getStateToPostToWebview()
formatter.raw(
JSON.stringify(
{
taskId,
message,
messageCount: state.clineMessages?.length || 0,
mode: state.mode,
},
null,
2,
),
)
}
// Reset yolo mode settings if they were enabled for this command
if (options.yolo) {
controller.stateManager.setGlobalState("yoloModeToggled", false)
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
}
await disposeEmbeddedController(logger)
} catch (error) {
formatter.error((error as Error).message)
// Reset yolo mode settings on error too
if (options.yolo) {
const controller = getControllerIfInitialized()
if (controller) {
controller.stateManager.setGlobalState("yoloModeToggled", false)
controller.stateManager.setGlobalState("maxConsecutiveMistakes", 3)
}
}
await disposeEmbeddedController(logger)
process.exit(1)
}
})
return sendCommand
}
+272
View File
@@ -0,0 +1,272 @@
/**
* Task view command - view conversation history using embedded Controller
*
* This command displays task conversation history from the embedded
* Controller, with options for real-time streaming and following.
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Command } from "commander"
import { CliWebviewAdapter } from "../../core/cli-webview-adapter.js"
import { disposeEmbeddedController, getEmbeddedController } from "../../core/embedded-controller.js"
import type { OutputFormatter } from "../../core/output/types.js"
import type { CliConfig } from "../../types/config.js"
import type { Logger } from "../../types/logger.js"
/**
* Format a ClineMessage for display
*/
function formatMessageSummary(msg: ClineMessage): string {
const timestamp = new Date(msg.ts).toLocaleTimeString()
const type = msg.type.toUpperCase()
let subtype = ""
if (msg.say) {
subtype = ` [${msg.say}]`
} else if (msg.ask) {
subtype = ` [${msg.ask}]`
}
// Truncate long messages
let content = msg.text || ""
if (content.length > 100) {
content = content.slice(0, 97) + "..."
}
// Handle special message types
if (msg.say === "api_req_started" || msg.say === "api_req_finished") {
try {
const info = JSON.parse(msg.text || "{}")
if (info.tokensIn || info.tokensOut) {
content = `tokens: ${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
}
} catch {
// Keep original content
}
}
return `[${timestamp}] ${type}${subtype}: ${content.replace(/\n/g, " ")}`
}
/**
* Check if task is complete or awaiting input
*/
function isTaskComplete(messages: ClineMessage[]): boolean {
if (messages.length === 0) {
return false
}
const lastMessage = messages[messages.length - 1]
// Task is complete if last message is completion_result
if (lastMessage.ask === "completion_result" || lastMessage.say === "completion_result") {
return true
}
return false
}
/**
* Sleep for a given number of milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Create the task view command
*/
export function createTaskViewCommand(config: CliConfig, logger: Logger, formatter: OutputFormatter): Command {
const viewCommand = new Command("view")
.alias("v")
.description("View task conversation history using embedded Controller")
.argument("[taskId]", "Task ID to view (defaults to current or most recent task)")
.option("-f, --follow", "Stream updates in real-time", false)
.option("-c, --follow-complete", "Follow until task completion", false)
.option("-n, --last <count>", "Show only last N messages")
.option("--since <timestamp>", "Show messages since timestamp (Unix ms)")
.option("-r, --raw", "Show raw message data (useful for debugging)", false)
.action(async (taskIdArg: string | undefined, options) => {
logger.debug("Task view command called", { taskIdArg, options })
try {
// Initialize embedded controller
const controller = await getEmbeddedController(logger, config.configDir)
// Get task history to find the task
const state = await controller.getStateToPostToWebview()
const taskHistory = state.taskHistory || []
// Determine which task to view
let taskId = taskIdArg
let historyItem = null
if (taskId) {
// Find task by ID (support partial ID match)
historyItem = taskHistory.find((t) => t.id === taskId || t.id.startsWith(taskId || ""))
if (!historyItem) {
throw new Error(`Task not found: ${taskId}`)
}
taskId = historyItem.id
} else {
// Use current task or most recent
if (controller.task) {
taskId = controller.task.taskId
historyItem = taskHistory.find((t) => t.id === taskId)
} else if (taskHistory.length > 0) {
historyItem = taskHistory[0] // Most recent
taskId = historyItem.id
} else {
throw new Error("No tasks found. Create a task with 'cline task new'")
}
}
// Initialize task if not already active
if (!controller.task || controller.task.taskId !== taskId) {
if (historyItem) {
const taskData = await controller.getTaskWithId(taskId)
await controller.initTask(undefined, undefined, undefined, taskData.historyItem)
}
}
// Display task info header
formatter.info(`\nTask: ${taskId}`)
if (historyItem) {
formatter.info(`Status: ${historyItem.size ? "has content" : "empty"}`)
if (historyItem.task) {
const promptPreview = historyItem.task.slice(0, 60) + (historyItem.task.length > 60 ? "..." : "")
formatter.info(`Prompt: ${promptPreview}`)
}
}
formatter.raw("─".repeat(60))
// Get messages
let messages = controller.task?.messageStateHandler.getClineMessages() || []
// Filter by timestamp if provided
if (options.since) {
const sinceTs = parseInt(options.since, 10)
if (isNaN(sinceTs)) {
throw new Error(`Invalid timestamp: ${options.since}`)
}
messages = messages.filter((m) => m.ts > sinceTs)
}
// Limit to last N messages if specified
if (options.last) {
const count = parseInt(options.last, 10)
if (isNaN(count) || count < 1) {
throw new Error(`Invalid count: ${options.last}`)
}
messages = messages.slice(-count)
}
// Display messages
if (messages.length === 0) {
formatter.info("No messages yet")
} else {
if (options.raw) {
// Raw JSON output
for (const msg of messages) {
formatter.raw(JSON.stringify(msg, null, 2))
formatter.raw("")
}
} else {
// Formatted output using the adapter
const adapter = new CliWebviewAdapter(controller, formatter)
for (const msg of messages) {
adapter.outputMessage(msg)
}
}
}
// JSON output for non-follow mode
if (config.outputFormat === "json" && !options.follow && !options.followComplete) {
formatter.raw(
JSON.stringify(
{
taskId,
prompt: historyItem?.task,
messageCount: messages.length,
messages: options.raw ? messages : messages.map(formatMessageSummary),
},
null,
2,
),
)
await disposeEmbeddedController(logger)
return
}
// Handle follow mode
if (options.follow || options.followComplete) {
formatter.raw("")
formatter.info("Watching for new messages... (Ctrl+C to stop)")
formatter.raw("─".repeat(60))
let isRunning = true
let lastMessageCount = messages.length
// Create adapter for streaming output
const adapter = new CliWebviewAdapter(controller, formatter)
// Handle Ctrl+C gracefully
const cleanup = async () => {
isRunning = false
formatter.raw("")
formatter.info("Stopped watching")
adapter.stopListening()
await disposeEmbeddedController(logger)
process.exit(0)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
// Poll for new messages
const pollInterval = 100 // ms
while (isRunning) {
await sleep(pollInterval)
// Get current messages
const currentMessages = controller.task?.messageStateHandler.getClineMessages() || []
// Output new messages
if (currentMessages.length > lastMessageCount) {
const newMessages = currentMessages.slice(lastMessageCount)
for (const msg of newMessages) {
adapter.outputMessage(msg)
}
lastMessageCount = currentMessages.length
}
// Check if task completed (for --follow-complete)
if (options.followComplete && isTaskComplete(currentMessages)) {
formatter.raw("")
formatter.info("Task completed")
break
}
// Check if task was cleared
if (!controller.task) {
formatter.warn("Task was cleared")
break
}
}
// Remove listeners
process.removeListener("SIGINT", cleanup)
process.removeListener("SIGTERM", cleanup)
}
await disposeEmbeddedController(logger)
} catch (error) {
formatter.error((error as Error).message)
await disposeEmbeddedController(logger)
process.exit(1)
}
})
return viewCommand
}
+31
View File
@@ -0,0 +1,31 @@
import { Command } from "commander"
import type { CliConfig } from "../types/config.js"
import type { Logger } from "../types/logger.js"
// Version is injected at build time via esbuild define
declare const __CLINE_VERSION__: string
/**
* Get the Cline version from the build-time injected value
*/
export function getVersion(): string {
return __CLINE_VERSION__
}
/**
* Execute the version command - displays the Cline version
*/
export function runVersionCommand(config: CliConfig, logger: Logger): void {
const version = getVersion()
logger.debug(`Displaying version: ${version}`)
console.log(`cline ${version}`)
}
/**
* Create the version subcommand
*/
export function createVersionCommand(config: CliConfig, logger: Logger): Command {
return new Command("version").description("Display the Cline version").action(() => {
runVersionCommand(config, logger)
})
}
+100
View File
@@ -0,0 +1,100 @@
/**
* API Provider definitions for authentication
*/
/**
* Provider information for authentication
*/
export interface ProviderInfo {
/** Provider identifier */
id: string
/** Display name */
name: string
/** Description for the interactive wizard */
description: string
/** Whether this provider requires an API key */
requiresApiKey: boolean
/** Environment variable name for API key (if any) */
envVar?: string
/** URL to get an API key */
keyUrl?: string
/** Whether this provider supports OAuth */
supportsOAuth?: boolean
}
/**
* Available API providers
*/
export const PROVIDERS: ProviderInfo[] = [
{
id: "anthropic",
name: "Anthropic",
description: "Direct access to Claude models via Anthropic API",
requiresApiKey: true,
envVar: "ANTHROPIC_API_KEY",
keyUrl: "https://console.anthropic.com/settings/keys",
},
{
id: "openrouter",
name: "OpenRouter",
description: "Access multiple AI providers through a single API",
requiresApiKey: true,
envVar: "OPENROUTER_API_KEY",
keyUrl: "https://openrouter.ai/keys",
},
{
id: "openai",
name: "OpenAI",
description: "Access to GPT models via OpenAI API",
requiresApiKey: true,
envVar: "OPENAI_API_KEY",
keyUrl: "https://platform.openai.com/api-keys",
},
{
id: "bedrock",
name: "AWS Bedrock",
description: "AWS Bedrock with Claude and other models (uses AWS credentials from environment or ~/.aws/credentials)",
requiresApiKey: false,
},
{
id: "gemini",
name: "Google Gemini",
description: "Access to Gemini models via Google AI API",
requiresApiKey: true,
envVar: "GOOGLE_API_KEY",
keyUrl: "https://aistudio.google.com/app/apikey",
},
{
id: "ollama",
name: "Ollama",
description: "Local models via Ollama (no API key required)",
requiresApiKey: false,
},
{
id: "lmstudio",
name: "LM Studio",
description: "Local models via LM Studio (no API key required)",
requiresApiKey: false,
},
]
/**
* Get provider by ID
*/
export function getProviderById(id: string): ProviderInfo | undefined {
return PROVIDERS.find((p) => p.id === id)
}
/**
* Get all provider IDs
*/
export function getProviderIds(): string[] {
return PROVIDERS.map((p) => p.id)
}
/**
* Check if a provider ID is valid
*/
export function isValidProviderId(id: string): boolean {
return PROVIDERS.some((p) => p.id === id)
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Secrets storage for API keys
* Stores API keys in ~/.cline/secrets.json with restricted permissions
*/
import fs from "fs"
import path from "path"
import { getDefaultConfigDir } from "../config.js"
/**
* Stored secrets schema
*/
export interface StoredSecrets {
[providerId: string]: string
}
/**
* Secrets storage class
*/
export class SecretsStorage {
private secretsPath: string
private configDir: string
constructor(configDir?: string) {
this.configDir = configDir || getDefaultConfigDir()
this.secretsPath = path.join(this.configDir, "secrets.json")
}
/**
* Ensure the config directory exists with proper permissions
*/
private ensureConfigDir(): void {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true, mode: 0o700 })
}
}
/**
* Load secrets from disk
*/
load(): StoredSecrets {
try {
if (fs.existsSync(this.secretsPath)) {
const content = fs.readFileSync(this.secretsPath, "utf-8")
return JSON.parse(content) as StoredSecrets
}
} catch {
// Return empty on error
}
return {}
}
/**
* Save secrets to disk with restricted permissions
*/
save(secrets: StoredSecrets): void {
this.ensureConfigDir()
fs.writeFileSync(this.secretsPath, JSON.stringify(secrets, null, 2), {
mode: 0o600, // Read/write for owner only
})
}
/**
* Get API key for a provider
*/
getApiKey(providerId: string): string | undefined {
const secrets = this.load()
return secrets[providerId]
}
/**
* Set API key for a provider
*/
setApiKey(providerId: string, apiKey: string): void {
const secrets = this.load()
secrets[providerId] = apiKey
this.save(secrets)
}
/**
* Delete API key for a provider
*/
deleteApiKey(providerId: string): boolean {
const secrets = this.load()
if (providerId in secrets) {
delete secrets[providerId]
this.save(secrets)
return true
}
return false
}
/**
* List all providers with stored keys
*/
listProviders(): string[] {
const secrets = this.load()
return Object.keys(secrets)
}
/**
* Check if a provider has a stored key
*/
hasApiKey(providerId: string): boolean {
const secrets = this.load()
return providerId in secrets
}
/**
* Get the path to the secrets file
*/
getSecretsPath(): string {
return this.secretsPath
}
/**
* Clear all secrets
*/
clear(): void {
this.save({})
}
}
/**
* Create a secrets storage instance
*/
export function createSecretsStorage(configDir?: string): SecretsStorage {
return new SecretsStorage(configDir)
}
/**
* Mask an API key for display (show first/last 4 chars)
*/
export function maskApiKey(key: string): string {
if (key.length <= 8) {
return "****"
}
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
+166
View File
@@ -0,0 +1,166 @@
/**
* CLI Webview Adapter
*
* This module bridges the Controller's state updates with terminal output.
* It coordinates between state subscriptions and message renderers to format
* ClineMessages for display in the terminal.
*
* Architecture:
* - StateSubscriber: Handles gRPC subscriptions and message tracking
* - SayMessageRenderer: Renders "say" type messages
* - AskMessageRenderer: Renders "ask" type messages
* - ToolRenderer: Renders tool operations and approvals
* - BrowserActionRenderer: Renders browser actions
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { Controller } from "@/core/controller"
import {
AskMessageRenderer,
BrowserActionRenderer,
type RenderContext,
SayMessageRenderer,
ToolRenderer,
} from "./message-rendering/index.js"
import type { OutputFormatter } from "./output/types.js"
import { type ActivitySpinner, createActivitySpinner } from "./spinner.js"
import { type StateChangeHandler, StateSubscriber } from "./state-subscription/index.js"
// Re-export for consumers
export type { StateChangeHandler } from "./state-subscription/index.js"
/**
* CLI Webview Adapter class
*
* Subscribes to Controller state updates and outputs messages to the terminal.
* Acts as a coordinator between state subscriptions and message rendering.
*/
export class CliWebviewAdapter {
private stateSubscriber: StateSubscriber
private sayRenderer: SayMessageRenderer
private askRenderer: AskMessageRenderer
private _currentOptions: string[] = []
private activitySpinner: ActivitySpinner
private isProcessing = false
private onStateChange?: StateChangeHandler
constructor(
private controller: Controller,
private formatter: OutputFormatter,
) {
// Create activity spinner that shows after 1 second of inactivity
this.activitySpinner = createActivitySpinner({
message: "Working hard...",
delayMs: 1000,
})
// Create render context for all renderers
const renderContext: RenderContext = {
formatter: this.formatter,
getMessages: () => this.getMessages(),
setCurrentOptions: (options: string[]) => {
this._currentOptions = options
},
}
// Create renderers
const toolRenderer = new ToolRenderer(renderContext)
const browserRenderer = new BrowserActionRenderer(renderContext)
this.sayRenderer = new SayMessageRenderer(renderContext, toolRenderer, browserRenderer)
this.askRenderer = new AskMessageRenderer(renderContext, toolRenderer)
// Create state subscriber
this.stateSubscriber = new StateSubscriber(this.controller, {
onStateChange: (messages) => this.onStateChange?.(messages),
onCompleteMessage: (msg) => this.outputMessage(msg),
getMessages: () => this.getMessages(),
onActivity: () => {
if (this.isProcessing) {
this.activitySpinner.reportActivity()
}
},
})
}
/**
* Get the current options for numbered selection
*/
get currentOptions(): string[] {
return this._currentOptions
}
/**
* Set whether the AI is currently processing
*
* When processing is true, the spinner will start monitoring for inactivity.
* When processing is false (e.g., waiting for user input), the spinner is disabled.
*/
setProcessing(processing: boolean): void {
this.isProcessing = processing
this.activitySpinner.setEnabled(processing)
if (processing) {
// Start monitoring for inactivity
this.activitySpinner.startMonitoring("Processing...")
} else {
// Stop spinner when not processing
this.activitySpinner.stop()
}
}
/**
* Start listening for state updates
*
* @param onStateChange - Optional callback for raw state changes
*/
startListening(onStateChange?: StateChangeHandler): void {
this.onStateChange = onStateChange
this.stateSubscriber.start()
}
/**
* Stop listening for state updates
*/
stopListening(): void {
this.stateSubscriber.stop()
this.activitySpinner.stop()
}
/**
* Output a ClineMessage to the terminal
*/
outputMessage(msg: ClineMessage): void {
if (msg.type === "say") {
this.sayRenderer.render(msg)
} else if (msg.type === "ask") {
this.askRenderer.render(msg)
}
}
/**
* Get the current messages from the Controller
*/
getMessages(): ClineMessage[] {
return this.controller.task?.messageStateHandler.getClineMessages() || []
}
/**
* Reset the message counter (useful when starting a new task)
*/
resetMessageCounter(): void {
this.stateSubscriber.reset()
}
/**
* Output all current messages (useful for initial display)
*/
outputAllMessages(): void {
const messages = this.getMessages()
for (const msg of messages) {
if (!msg.partial && !this.stateSubscriber.hasBeenPrinted(msg.ts)) {
this.outputMessage(msg)
this.stateSubscriber.markPrinted(msg.ts)
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import os from "os"
import path from "path"
import type { CliConfig, PartialCliConfig } from "../types/config.js"
import { getDefaultFormat } from "./output/index.js"
/**
* Get the default Cline configuration directory
* @returns Path to ~/.cline
*/
export function getDefaultConfigDir(): string {
return path.join(os.homedir(), ".cline")
}
/**
* Default CLI configuration values
*/
export const DEFAULT_CLI_CONFIG: CliConfig = {
verbose: false,
configDir: getDefaultConfigDir(),
outputFormat: getDefaultFormat(),
}
/**
* Create a CLI configuration by merging defaults with provided options
*/
export function createConfig(options: PartialCliConfig = {}): CliConfig {
return {
...DEFAULT_CLI_CONFIG,
...options,
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Console output filtering for CLI mode
*
* Intercepts all console methods (log, info, debug, warn, error) to suppress
* noisy operational messages unless verbose mode is enabled.
*
* This must be called EARLY in CLI startup, before any other code runs,
* to ensure all console output is filtered.
*/
// Store original console methods for restoration
const originalConsole = {
log: console.log,
info: console.info,
debug: console.debug,
warn: console.warn,
error: console.error,
}
// Patterns that indicate noisy operational output
export const NOISE_PATTERNS = [
// Telemetry & Feature Flags
"Telemetry distinct ID",
"Changing telemetry ID",
"TelemetryService",
"TelemetryProviderFactory",
"NoOpTelemetryProvider",
"NoOpFeatureFlagsProvider",
"NoOpErrorProvider",
"identifyUser",
// Storage & Migration
"Storage Migration",
"FileContextTracker",
// Checkpoints & Git Operations
"CheckpointTracker",
"checkpoint",
"Checkpoint",
"Repository ID",
"cwdHash",
"shadow git",
"Shadow git",
"Getting diff count between commits",
"diff count",
// Task & Lock Management
"Lock manager not available",
"Task lock",
"Skipping Checkpoints lock",
"Todo file watcher",
"[Task",
// Workspace & Terminal
"WorkspaceManager",
"TerminalManager",
"StandaloneTerminalRegistry",
"StandaloneTerminal",
// Focus Chain
"focus chain",
"Focus Chain",
// Server & Initialization
"#bot.cline.server.ts",
"instantiated",
"for legacy",
// Registry
"Registry health check",
// Debug markers
"[DEBUG]",
"[OTEL",
// MCP
"[MCP",
// Component warnings that are not errors
"Component '",
"Warning: Component",
// Controller lifecycle (not actual errors)
"Controller disposed",
"[INFO ] Executing command",
]
/**
* Check if a message should be suppressed based on noise patterns
*/
function shouldSuppress(args: unknown[]): boolean {
const message = args.map(String).join(" ")
return NOISE_PATTERNS.some((pattern) => message.includes(pattern))
}
/**
* Apply console filtering to suppress noisy output
*
* @param verbose - If true, no filtering is applied (all output shown)
*/
export function applyConsoleFilter(verbose: boolean): void {
if (verbose) {
// In verbose mode, restore original methods (no filtering)
restoreConsole()
return
}
// Replace console methods with filtered versions
console.log = (...args: unknown[]) => {
if (!shouldSuppress(args)) {
originalConsole.log.apply(console, args)
}
}
console.info = (...args: unknown[]) => {
if (!shouldSuppress(args)) {
originalConsole.info.apply(console, args)
}
}
console.warn = (...args: unknown[]) => {
if (!shouldSuppress(args)) {
originalConsole.warn.apply(console, args)
}
}
console.error = (...args: unknown[]) => {
if (!shouldSuppress(args)) {
originalConsole.error.apply(console, args)
}
}
// Always suppress debug in non-verbose mode
console.debug = () => {
// No-op
}
}
/**
* Restore original console methods
*
* Useful for testing or when verbose mode is toggled
*/
export function restoreConsole(): void {
console.log = originalConsole.log
console.info = originalConsole.info
console.debug = originalConsole.debug
console.warn = originalConsole.warn
console.error = originalConsole.error
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Re-export the VSCode context initialization from the standalone module
*
* This reuses the existing implementation that creates a VSCode-like
* ExtensionContext for standalone (non-VSCode) mode.
*/
export { initializeContext } from "@/standalone/vscode-context"
+188
View File
@@ -0,0 +1,188 @@
/**
* Embedded Controller for CLI
*
* This module initializes a Cline Controller directly in the CLI process,
* allowing CLI commands (chat, send, view) to interact with Cline's AI
* without requiring a separate gRPC server.
*/
import { initialize, tearDown } from "@/common"
import { Controller } from "@/core/controller"
import type { WebviewProvider } from "@/core/webview"
import { initializeContext } from "@/standalone/vscode-context"
import type { Logger } from "../types/logger.js"
import { isHostProviderInitialized, setupHostProvider } from "./host-provider-setup.js"
// Singleton instance of the embedded controller
let embeddedController: Controller | undefined
let webviewProvider: WebviewProvider | undefined
let initializationPromise: Promise<Controller> | undefined
let isInitializing = false
/**
* Get or create an embedded Controller instance for CLI usage
*
* This function is idempotent - calling it multiple times will return
* the same Controller instance.
*
* @param logger - Logger instance for CLI output
* @param configDir - Optional custom config directory (defaults to ~/.cline)
* @returns Promise resolving to the Controller instance
*/
export async function getEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
// Return existing instance if available
if (embeddedController) {
return embeddedController
}
// Return in-progress initialization if one exists
if (initializationPromise) {
return initializationPromise
}
// Start new initialization
initializationPromise = initializeEmbeddedController(logger, configDir)
try {
embeddedController = await initializationPromise
return embeddedController
} catch (error) {
// Clear the promise so we can retry
initializationPromise = undefined
throw error
}
}
/**
* Initialize the embedded Controller
*
* @param logger - Logger instance for CLI output
* @param configDir - Optional custom config directory
* @returns Promise resolving to the Controller instance
*/
async function initializeEmbeddedController(logger: Logger, configDir?: string): Promise<Controller> {
if (isInitializing) {
throw new Error("Controller initialization already in progress")
}
isInitializing = true
try {
logger.debug("Initializing embedded controller...")
// Initialize VSCode-like context with storage directories
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
logger.debug(`Using data directory: ${DATA_DIR}`)
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
// Setup HostProvider if not already initialized
if (!isHostProviderInitialized()) {
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
logger.debug("HostProvider initialized")
}
// Initialize the extension common components and get WebviewProvider
webviewProvider = await initialize(extensionContext)
// The controller is available via the webviewProvider
const controller = webviewProvider.controller
logger.debug("Embedded controller initialized successfully")
return controller
} catch (error) {
logger.error(`Failed to initialize embedded controller: ${error}`)
throw error
} finally {
isInitializing = false
}
}
/**
* Get the current Controller instance without initializing
*
* @returns The Controller instance if initialized, undefined otherwise
*/
export function getControllerIfInitialized(): Controller | undefined {
return embeddedController
}
/**
* Check if the embedded Controller is initialized
*
* @returns true if initialized, false otherwise
*/
export function isControllerInitialized(): boolean {
return embeddedController !== undefined
}
/**
* Dispose the embedded Controller and clean up resources
*
* This should be called when the CLI process exits to ensure
* proper cleanup of resources.
*
* @param logger - Logger instance for output
*/
export async function disposeEmbeddedController(logger: Logger): Promise<void> {
if (!embeddedController) {
return
}
try {
logger.debug("Disposing embedded controller...")
// Dispose the controller
await embeddedController.dispose()
// Tear down common services
await tearDown()
embeddedController = undefined
webviewProvider = undefined
initializationPromise = undefined
logger.debug("Embedded controller disposed")
} catch (error) {
logger.error(`Error disposing embedded controller: ${error}`)
}
}
/**
* Get the WebviewProvider instance
*
* The WebviewProvider wraps the Controller and provides access to
* the webview-related functionality.
*
* @returns The WebviewProvider instance if initialized, undefined otherwise
*/
export function getWebviewProvider(): WebviewProvider | undefined {
return webviewProvider
}
/**
* Initialize only the HostProvider for lightweight CLI operations
*
* This is a minimal initialization that sets up just enough infrastructure
* to read task history and messages from disk, without initializing the
* full Controller (which starts MCP servers, etc.)
*
* Use this for read-only operations like `task dump` and `task list`.
*
* @param logger - Logger instance for CLI output
* @param configDir - Optional custom config directory (defaults to ~/.cline)
*/
export function initializeHostProviderOnly(logger: Logger, configDir?: string): void {
if (isHostProviderInitialized()) {
return
}
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeContext(configDir)
logger.debug(`Using data directory: ${DATA_DIR}`)
logger.debug(`Using extension directory: ${EXTENSION_DIR}`)
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR, logger)
logger.debug("HostProvider initialized (lightweight mode)")
}
+153
View File
@@ -0,0 +1,153 @@
import { ExternalCommentReviewController } from "@hosts/external/ExternalCommentReviewController"
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
import { execSync } from "child_process"
import type { ExtensionContext } from "vscode"
import type { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { StandaloneTerminalManager } from "@/integrations/terminal"
import { StandaloneDiffViewProvider } from "../integrations/editor/StandaloneDiffViewProvider.js"
import type { Logger } from "../types/logger.js"
import { NOISE_PATTERNS } from "./console-filter.js"
import { StandaloneHostBridgeClient } from "./standalone-hostbridge-client.js"
/**
* Initialize the HostProvider for CLI mode
*
* This sets up the host provider with CLI-appropriate implementations
* of the various providers (webview, diff view, terminal, etc.)
*
* @param extensionContext - VSCode-like extension context
* @param extensionDir - Directory where the extension is installed
* @param dataDir - Directory for Cline data storage
* @param logger - Logger instance for output
*/
export function setupHostProvider(
extensionContext: ExtensionContext,
extensionDir: string,
dataDir: string,
logger: Logger,
): void {
const createWebview = (): WebviewProvider => {
return new ExternalWebviewProvider(extensionContext)
}
const createDiffView = (): DiffViewProvider => {
return new StandaloneDiffViewProvider((message) => logger.info(message))
}
const createCommentReview = () => new ExternalCommentReviewController()
const createTerminalManager = () => new StandaloneTerminalManager()
const getCallbackUrl = async (): Promise<string> => {
return AuthHandler.getInstance().getCallbackUrl()
}
const getBinaryLocation = async (name: string): Promise<string> => {
// For ripgrep, use the bundled @vscode/ripgrep package
if (name === "rg") {
try {
// Dynamic import to handle the external module
const { rgPath } = await import("@vscode/ripgrep")
return rgPath
} catch {
// Fallback to system ripgrep if @vscode/ripgrep is not available
try {
return execSync("which rg", { encoding: "utf-8" }).trim()
} catch {
throw new Error("ripgrep (rg) not found. Please install it via 'brew install ripgrep' or equivalent.")
}
}
}
// For other binaries, try to find them in PATH
try {
return execSync(`which ${name}`, { encoding: "utf-8" }).trim()
} catch {
throw new Error(`Binary '${name}' not found in PATH.`)
}
}
const logToChannel = (message: string): void => {
// Parse log level from message (format: "LEVEL message...")
// Core Logger outputs messages as "${level} ${fullMessage}"
const parts = message.split(" ")
const level = parts[0]?.toUpperCase()
const content = parts.slice(1).join(" ")
// Route to appropriate logger method based on parsed level
// This respects the CLI's --verbose flag for DEBUG messages
switch (level) {
case "DEBUG":
case "TRACE":
logger.debug(content)
break
case "WARN":
logger.warn(content)
break
case "ERROR":
logger.error(content)
break
case "INFO":
case "LOG":
default: {
// Filter out noisy INFO patterns (they go to debug instead)
const messageToCheck = content || message
const isNoise = NOISE_PATTERNS.some((pattern) => messageToCheck.includes(pattern))
if (isNoise) {
logger.debug(messageToCheck)
} else {
logger.info(messageToCheck)
}
break
}
}
}
HostProvider.initialize(
createWebview,
createDiffView,
createCommentReview,
createTerminalManager,
new StandaloneHostBridgeClient(),
logToChannel,
getCallbackUrl,
getBinaryLocation,
extensionDir,
dataDir,
)
}
/**
* Check if HostProvider is already initialized
*/
export function isHostProviderInitialized(): boolean {
return HostProvider.isInitialized()
}
/**
* Reset the HostProvider (primarily for testing)
*/
export function resetHostProvider(): void {
HostProvider.reset()
}
/**
* Enable the AuthHandler for OAuth callback support
* Must be called before initiating any OAuth flows
*/
export function enableAuthHandler(): void {
AuthHandler.getInstance().setEnabled(true)
}
/**
* Disable and stop the AuthHandler
* Should be called when auth is complete or on cleanup
*/
export function disableAuthHandler(): void {
const handler = AuthHandler.getInstance()
handler.setEnabled(false)
handler.stop()
}
+64
View File
@@ -0,0 +1,64 @@
import type { Logger } from "../types/logger.js"
import { LogLevel } from "../types/logger.js"
/**
* Console-based logger with verbose mode support
*/
export class ConsoleLogger implements Logger {
private verbose: boolean
constructor(verbose: boolean = false) {
this.verbose = verbose
}
/**
* Check if a message at the given level should be logged
*/
private shouldLog(level: LogLevel): boolean {
// TODO - implement log level filtering
if (level === LogLevel.DEBUG) {
return this.verbose
}
return true
}
/**
* Format a log message with timestamp and level
*/
private formatMessage(level: LogLevel, message: string): string {
const timestamp = new Date().toISOString()
const levelUpper = level.toUpperCase().padEnd(5)
return `[${timestamp}] [${levelUpper}] ${message}`
}
debug(message: string, ...args: unknown[]): void {
if (this.shouldLog(LogLevel.DEBUG)) {
console.debug(this.formatMessage(LogLevel.DEBUG, message), ...args)
}
}
info(message: string, ...args: unknown[]): void {
if (this.shouldLog(LogLevel.INFO)) {
console.info(this.formatMessage(LogLevel.INFO, message), ...args)
}
}
warn(message: string, ...args: unknown[]): void {
if (this.shouldLog(LogLevel.WARN)) {
console.warn(this.formatMessage(LogLevel.WARN, message), ...args)
}
}
error(message: string, ...args: unknown[]): void {
if (this.shouldLog(LogLevel.ERROR)) {
console.error(this.formatMessage(LogLevel.ERROR, message), ...args)
}
}
}
/**
* Factory function to create a logger instance
*/
export function createLogger(verbose: boolean = false): Logger {
return new ConsoleLogger(verbose)
}
@@ -0,0 +1,210 @@
/**
* Ask Message Renderer
*
* Handles rendering of "ask" type ClineMessages, which require user input
* or approval. Also includes MCP server approval rendering.
*/
import type { ClineAskQuestion, ClineAskUseMcpServer, ClineMessage, ClinePlanModeResponse } from "@shared/ExtensionMessage"
import { renderMarkdown } from "./markdown-renderer.js"
import type { ToolRenderer } from "./tool-renderer.js"
import type { RenderContext } from "./types.js"
/**
* AskMessageRenderer class
*
* Renders "ask" type messages to the terminal, including:
* - Followup questions with options
* - Command approval requests
* - Tool approval requests
* - API failure prompts
* - Browser launch approval
* - MCP server approval
* - Plan mode responses
*/
export class AskMessageRenderer {
constructor(
private ctx: RenderContext,
private toolRenderer: ToolRenderer,
) {}
/**
* Render an "ask" type message
*
* @param msg - The ClineMessage to render
*/
render(msg: ClineMessage): void {
const ask = msg.ask
switch (ask) {
case "followup":
this.renderFollowupQuestion(msg)
break
case "plan_mode_respond":
this.renderPlanModeResponse(msg)
break
case "command":
this.ctx.formatter.raw(`\n💻 Execute command?`)
this.ctx.formatter.raw(` $ ${msg.text || ""}`)
this.ctx.formatter.info(" [y/n] or [yy to auto-approve commands]")
break
case "tool":
this.toolRenderer.renderToolApproval(msg)
break
case "api_req_failed":
this.ctx.formatter.error(`\n❌ API request failed`)
if (msg.text) {
this.ctx.formatter.raw(` ${msg.text}`)
}
this.ctx.formatter.info(" [retry/cancel]")
break
case "resume_task":
// this.ctx.formatter.info(`\n⏸ Task paused. Resume?`)
// this.ctx.formatter.info(" [yes/no]")
break
case "completion_result":
// TODO end process if yolo mode
this.ctx.formatter.success(`\n✅ Task Complete!`)
if (msg.text) {
this.ctx.formatter.raw(msg.text)
}
break
case "browser_action_launch":
this.ctx.formatter.raw(`\n🌐 Launch browser?`)
if (msg.text) {
this.ctx.formatter.raw(` URL: ${msg.text}`)
}
this.ctx.formatter.info(" [y/n] or [yy to auto-approve browser]")
break
case "use_mcp_server":
this.renderMcpServerApproval(msg)
break
case "mistake_limit_reached":
this.ctx.formatter.warn(`\n! Mistake limit reached`)
if (msg.text) {
this.ctx.formatter.raw(msg.text)
}
this.ctx.formatter.info(" [continue/stop]")
break
default:
if (msg.text) {
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
}
}
}
/**
* Render a followup question with numbered options
*
* @param msg - The ClineMessage with question information
*/
private renderFollowupQuestion(msg: ClineMessage): void {
// Clear previous options
this.ctx.setCurrentOptions([])
if (!msg.text) {
this.ctx.formatter.raw(`\n❓ Question`)
return
}
try {
const question = JSON.parse(msg.text) as ClineAskQuestion
this.ctx.formatter.raw(`\n❓ ${question.question}`)
// Display options as numbered list if present
if (question.options && question.options.length > 0) {
this.ctx.setCurrentOptions(question.options)
this.ctx.formatter.raw("")
for (let i = 0; i < question.options.length; i++) {
this.ctx.formatter.raw(` ${i + 1}. ${question.options[i]}`)
}
this.ctx.formatter.raw("")
this.ctx.formatter.info(" Enter a number to select, or type your response:")
}
} catch {
// Not JSON, output as plain text
this.ctx.formatter.raw(`\n❓ ${msg.text}`)
}
}
/**
* Render a plan mode response with markdown rendering
*
* @param msg - The ClineMessage with plan mode response
*/
private renderPlanModeResponse(msg: ClineMessage): void {
// Clear previous options
this.ctx.setCurrentOptions([])
if (!msg.text) {
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
return
}
try {
const planResponse = JSON.parse(msg.text) as ClinePlanModeResponse
this.ctx.formatter.raw("")
// Render the markdown response
const rendered = renderMarkdown(planResponse.response)
this.ctx.formatter.raw(rendered)
// Display options as numbered list if present
if (planResponse.options && planResponse.options.length > 0) {
this.ctx.setCurrentOptions(planResponse.options)
this.ctx.formatter.raw("")
for (let i = 0; i < planResponse.options.length; i++) {
this.ctx.formatter.raw(` ${i + 1}. ${planResponse.options[i]}`)
}
this.ctx.formatter.raw("")
this.ctx.formatter.info(" Enter a number to select, or type your response:")
} else {
this.ctx.formatter.raw("")
this.ctx.formatter.info(" Toggle to Act mode to execute, or provide feedback:")
}
} catch {
// Not JSON, output as plain text
this.ctx.formatter.info(`\n📝 Plan Mode Response Required`)
this.ctx.formatter.raw(msg.text)
}
}
/**
* Render MCP server approval request
*
* @param msg - The ClineMessage with MCP server information
*/
private renderMcpServerApproval(msg: ClineMessage): void {
if (!msg.text) {
this.ctx.formatter.raw(`\n🔌 MCP server approval required`)
return
}
try {
const mcp = JSON.parse(msg.text) as ClineAskUseMcpServer
this.ctx.formatter.raw(`\n🔌 MCP: ${mcp.serverName}`)
if (mcp.type === "use_mcp_tool" && mcp.toolName) {
this.ctx.formatter.raw(` Tool: ${mcp.toolName}`)
if (mcp.arguments) {
this.ctx.formatter.raw(` Args: ${mcp.arguments}`)
}
} else if (mcp.type === "access_mcp_resource" && mcp.uri) {
this.ctx.formatter.raw(` Resource: ${mcp.uri}`)
}
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
} catch {
this.ctx.formatter.raw(`\n🔌 MCP approval: ${msg.text}`)
this.ctx.formatter.info(" [y/n] or [yy to auto-approve MCP]")
}
}
}
@@ -0,0 +1,84 @@
/**
* Browser Action Renderer
*
* Handles rendering of browser-related messages including browser actions
* and their results.
*/
import type { BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
import type { RenderContext } from "./types.js"
/**
* BrowserActionRenderer class
*
* Renders browser action messages to the terminal, including:
* - Browser launch, click, type, scroll, and close actions
* - Browser action results with URL and console output
*/
export class BrowserActionRenderer {
constructor(private ctx: RenderContext) {}
/**
* Render a browser action message
*
* @param msg - The ClineMessage with browser action information
*/
renderBrowserAction(msg: ClineMessage): void {
if (!msg.text) {
return
}
try {
const action = JSON.parse(msg.text) as ClineSayBrowserAction
switch (action.action) {
case "launch":
this.ctx.formatter.raw(`\n🌐 Browser: Launching...`)
break
case "click":
this.ctx.formatter.raw(`\n🖱 Browser: Click at ${action.coordinate || "position"}`)
break
case "type":
this.ctx.formatter.raw(`\n⌨ Browser: Type "${action.text || ""}"`)
break
case "scroll_down":
this.ctx.formatter.raw(`\n📜 Browser: Scroll down`)
break
case "scroll_up":
this.ctx.formatter.raw(`\n📜 Browser: Scroll up`)
break
case "close":
this.ctx.formatter.raw(`\n🌐 Browser: Closing...`)
break
}
} catch {
this.ctx.formatter.raw(`\n🌐 Browser: ${msg.text}`)
}
}
/**
* Render a browser action result message
*
* @param msg - The ClineMessage with browser action result
*/
renderBrowserActionResult(msg: ClineMessage): void {
if (!msg.text) {
return
}
try {
const result = JSON.parse(msg.text) as BrowserActionResult
if (result.currentUrl) {
this.ctx.formatter.raw(` URL: ${result.currentUrl}`)
}
if (result.logs) {
this.ctx.formatter.raw(` Console: ${result.logs}`)
}
// Note: Screenshots are not displayed in terminal
if (result.screenshot) {
this.ctx.formatter.raw(` 📷 Screenshot captured`)
}
} catch {
this.ctx.formatter.raw(` ${msg.text}`)
}
}
}
@@ -0,0 +1,16 @@
/**
* Message Rendering Module
*
* Provides modular, testable renderers for formatting ClineMessages
* for terminal output.
*/
// Renderers
export { AskMessageRenderer } from "./ask-message-renderer.js"
export { BrowserActionRenderer } from "./browser-action-renderer.js"
// Utilities
export { renderMarkdown } from "./markdown-renderer.js"
export { SayMessageRenderer } from "./say-message-renderer.js"
export { ToolRenderer } from "./tool-renderer.js"
// Types
export type { MessageRenderer, RenderContext } from "./types.js"
@@ -0,0 +1,38 @@
/**
* Markdown Renderer
*
* Handles converting markdown text to terminal-formatted output
* using the marked library with terminal-specific styling.
*/
import chalk from "chalk"
import { type MarkedExtension, marked } from "marked"
import { markedTerminal } from "marked-terminal"
// Configure marked with terminal renderer
// Note: @types/marked-terminal is outdated and returns wrong type, cast to MarkedExtension
marked.use(
markedTerminal({
heading: chalk.cyan.bold,
firstHeading: chalk.magenta.bold.underline,
strong: chalk.yellow.bold,
em: chalk.blue.italic,
codespan: chalk.greenBright,
}) as unknown as MarkedExtension,
)
/**
* Render markdown text to terminal-formatted output
*
* @param text - Markdown text to render
* @returns Terminal-formatted string
*/
export function renderMarkdown(text: string): string {
try {
const rendered = marked.parse(text)
// marked.parse returns string | Promise<string>, we only use sync mode
return (typeof rendered === "string" ? rendered : text).trim()
} catch {
return text
}
}
@@ -0,0 +1,186 @@
/**
* Say Message Renderer
*
* Handles rendering of "say" type ClineMessages, which are informational
* messages that don't require user input.
*/
import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { getApiMetrics } from "@shared/getApiMetrics"
import type { BrowserActionRenderer } from "./browser-action-renderer.js"
import { renderMarkdown } from "./markdown-renderer.js"
import type { ToolRenderer } from "./tool-renderer.js"
import type { RenderContext } from "./types.js"
/**
* SayMessageRenderer class
*
* Renders "say" type messages to the terminal, including:
* - Task information
* - AI text and reasoning
* - Errors and retries
* - API request status
* - Command output
* - Tool and browser actions
* - Checkpoints
*/
export class SayMessageRenderer {
constructor(
private ctx: RenderContext,
private toolRenderer: ToolRenderer,
private browserRenderer: BrowserActionRenderer,
) {}
/**
* Render a "say" type message
*
* @param msg - The ClineMessage to render
*/
render(msg: ClineMessage): void {
const say = msg.say
switch (say) {
case "task":
this.ctx.formatter.info(`\n📋 Task: ${msg.text || ""}`)
break
case "text":
case "reasoning":
if (msg.text) {
// Check if this is reasoning content
if (say === "reasoning" || msg.reasoning) {
this.ctx.formatter.raw(`💭 ${msg.reasoning || msg.text}`)
} else {
this.ctx.formatter.raw(msg.text)
}
}
break
case "error":
this.ctx.formatter.error(`${msg.text || "An error occurred"}`)
break
case "error_retry":
this.ctx.formatter.warn(`🔄 Retrying: ${msg.text || ""}`)
break
case "api_req_started":
this.renderApiReqStarted()
break
case "api_req_finished":
this.renderApiReqFinished(msg)
break
case "completion_result":
this.ctx.formatter.success(`\n✨ "Task completed"`)
this.ctx.formatter.raw(renderMarkdown(msg.text || ""))
break
case "user_feedback":
this.ctx.formatter.info(`📝 User: ${msg.text || ""}`)
break
case "command":
this.ctx.formatter.code(`\n$ ${msg.text || ""}`)
break
case "command_output":
if (msg.text) {
// Indent command output
const lines = msg.text.split("\n")
for (const line of lines) {
this.ctx.formatter.raw(` ${line}`)
}
}
break
case "tool":
this.toolRenderer.renderToolMessage(msg)
break
case "browser_action":
this.browserRenderer.renderBrowserAction(msg)
break
case "browser_action_result":
this.browserRenderer.renderBrowserActionResult(msg)
break
case "mcp_server_request_started":
this.ctx.formatter.info(`🔌 MCP request: ${msg.text || ""}`)
break
case "mcp_server_response":
this.ctx.formatter.raw(` Response: ${msg.text || ""}`)
break
case "checkpoint_created":
// Display checkpoint ID (timestamp) so users can reference it for /restore
const hashInfo = msg.lastCheckpointHash ? ` (${msg.lastCheckpointHash.slice(0, 8)})` : ""
this.ctx.formatter.info(`💾 Checkpoint created [ID: ${msg.ts}]${hashInfo}`)
break
case "shell_integration_warning":
this.ctx.formatter.warn(`! Shell integration: ${msg.text || ""}`)
break
case "diff_error":
this.ctx.formatter.error(`❌ Diff error: ${msg.text || ""}`)
break
case "task_progress":
this.ctx.formatter.info("Making Progress...")
break
default:
// Handle any other say types
if (msg.text) {
this.ctx.formatter.raw(msg.text)
}
}
}
/**
* Render API request started message with cumulative session metrics
*/
private renderApiReqStarted(): void {
// Show cumulative session token usage
const messages = this.ctx.getMessages()
const metrics = getApiMetrics(messages)
const parts: string[] = []
// Token counts
parts.push(`${metrics.totalTokensIn.toLocaleString()} in / ${metrics.totalTokensOut.toLocaleString()} out`)
// Cache info if available
if (metrics.totalCacheReads !== undefined || metrics.totalCacheWrites !== undefined) {
const cacheReads = metrics.totalCacheReads ?? 0
const cacheWrites = metrics.totalCacheWrites ?? 0
parts.push(`cache: ${cacheReads.toLocaleString()}r/${cacheWrites.toLocaleString()}w`)
}
// Cost
if (metrics.totalCost > 0) {
parts.push(`$${metrics.totalCost.toFixed(4)}`)
}
this.ctx.formatter.info(`🔄 API request started... [Session: ${parts.join(" | ")}]`)
}
/**
* Render API request finished message with token counts and cost
*/
private renderApiReqFinished(msg: ClineMessage): void {
if (msg.text) {
try {
const info = JSON.parse(msg.text) as ClineApiReqInfo
const tokens = `${info.tokensIn || 0} in / ${info.tokensOut || 0} out`
const cost = info.cost ? ` ($${info.cost.toFixed(4)})` : ""
this.ctx.formatter.success(`✅ API request complete: ${tokens}${cost}`)
} catch {
this.ctx.formatter.success("✅ API request complete")
}
}
}
}
@@ -0,0 +1,150 @@
/**
* Tool Renderer
*
* Handles rendering of tool-related messages including tool operations,
* tool approval requests, and diff output formatting.
*/
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
import type { RenderContext } from "./types.js"
/**
* ToolRenderer class
*
* Renders tool-related messages to the terminal, including:
* - Tool operation results (file edits, reads, searches, etc.)
* - Tool approval requests with diffs
* - Diff formatting with color-coded additions/deletions
*/
export class ToolRenderer {
constructor(private ctx: RenderContext) {}
/**
* Render a tool operation message (say type)
*
* @param msg - The ClineMessage with tool information
*/
renderToolMessage(msg: ClineMessage): void {
if (!msg.text) {
return
}
try {
const tool = JSON.parse(msg.text) as ClineSayTool
switch (tool.tool) {
case "editedExistingFile":
this.ctx.formatter.raw(`\n📝 Edited: ${tool.path || "file"}`)
if (tool.diff) {
this.renderDiff(tool.diff)
}
break
case "newFileCreated":
this.ctx.formatter.raw(`\n📄 Created: ${tool.path || "file"}`)
break
case "fileDeleted":
this.ctx.formatter.raw(`\n🗑 Deleted: ${tool.path || "file"}`)
break
case "readFile":
this.ctx.formatter.raw(`\n📖 Read: ${tool.path || "file"}`)
break
case "listFilesTopLevel":
case "listFilesRecursive":
this.ctx.formatter.raw(`\n📂 Listed: ${tool.path || "directory"}`)
break
case "searchFiles":
this.ctx.formatter.raw(`\n🔍 Searched: ${tool.regex || "pattern"} in ${tool.path || "directory"}`)
break
case "webFetch":
case "webSearch":
this.ctx.formatter.raw(`\n🌐 Web: ${tool.content || ""}`)
break
default:
this.ctx.formatter.raw(`\n🔧 Tool: ${tool.tool}`)
}
} catch {
// Not JSON, just output raw
this.ctx.formatter.raw(`\n🔧 ${msg.text}`)
}
}
/**
* Render a tool approval request (ask type)
*
* @param msg - The ClineMessage with tool approval information
*/
renderToolApproval(msg: ClineMessage): void {
if (!msg.text) {
this.ctx.formatter.raw(`\n🔧 Tool approval required`)
return
}
try {
const tool = JSON.parse(msg.text) as ClineSayTool
this.ctx.formatter.raw(`\n🔧 Approve ${tool.tool}?`)
if (tool.path) {
this.ctx.formatter.raw(` Path: ${tool.path}`)
}
// Check both diff and content fields - the extension stores diffs in content field
const diffContent = tool.diff || tool.content
if (diffContent && (tool.tool === "editedExistingFile" || tool.tool === "newFileCreated")) {
this.renderDiff(diffContent)
}
// Show appropriate auto-approve hint based on tool type
const autoApproveHint = this.getAutoApproveHint(tool.tool)
this.ctx.formatter.info(` [y/n]${autoApproveHint}`)
} catch {
this.ctx.formatter.raw(`\n🔧 Tool approval: ${msg.text}`)
this.ctx.formatter.info(" [y/n] or [yy to auto-approve]")
}
}
/**
* Render diff content with color-coded additions and deletions
*
* @param diff - The diff string to render
*/
renderDiff(diff: string): void {
const lines = diff.split("\n")
for (const line of lines) {
if (line.startsWith("+")) {
this.ctx.formatter.raw(` \x1b[32m${line}\x1b[0m`) // Green for additions
} else if (line.startsWith("-")) {
this.ctx.formatter.raw(` \x1b[31m${line}\x1b[0m`) // Red for deletions
} else {
this.ctx.formatter.raw(` ${line}`)
}
}
}
/**
* Get the auto-approve hint text based on tool type
*
* @param toolType - The type of tool being approved
* @returns Hint text for auto-approval
*/
getAutoApproveHint(toolType: string): string {
switch (toolType) {
case "editedExistingFile":
case "newFileCreated":
case "fileDeleted":
return " or [yy to auto-approve edits]"
case "readFile":
case "listFilesTopLevel":
case "listFilesRecursive":
case "listCodeDefinitionNames":
case "searchFiles":
case "webFetch":
case "webSearch":
return " or [yy to auto-approve reads]"
default:
return " or [yy to auto-approve]"
}
}
}
@@ -0,0 +1,41 @@
/**
* Types and interfaces for message rendering
*
* These types define the contract between the CliWebviewAdapter and
* the various message renderers, enabling modular and testable rendering.
*/
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { OutputFormatter } from "../output/types.js"
/**
* Context provided to renderers for outputting messages
*
* This allows renderers to access shared resources without
* creating circular dependencies with the adapter.
*/
export interface RenderContext {
/** Formatter for outputting to the terminal */
formatter: OutputFormatter
/** Get current messages from the controller */
getMessages: () => ClineMessage[]
/** Set the current options for numbered selection (used by followup questions) */
setCurrentOptions: (options: string[]) => void
}
/**
* Interface for message renderers
*
* Each renderer is responsible for a specific category of messages
* (e.g., say messages, ask messages, tool messages).
*/
export interface MessageRenderer {
/**
* Render a message to the terminal
*
* @param msg - The ClineMessage to render
*/
render(msg: ClineMessage): void
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Output formatting system - factory and exports
*/
import { createJsonFormatter } from "./json-formatter.js"
import { createPlainFormatter } from "./plain-formatter.js"
import { createRichFormatter } from "./rich-formatter.js"
import type { OutputFormat, OutputFormatter } from "./types.js"
// Re-export formatter classes for direct use if needed
export { JsonFormatter } from "./json-formatter.js"
export { PlainFormatter } from "./plain-formatter.js"
export { RichFormatter } from "./rich-formatter.js"
// Re-export types
export type { ClineMessage, OutputFormat, OutputFormatter, TaskInfo } from "./types.js"
/**
* Default output format based on TTY detection
*/
export function getDefaultFormat(): OutputFormat {
// Use rich format if stdout is a TTY, otherwise plain
return process.stdout.isTTY ? "rich" : "plain"
}
/**
* Validate that a string is a valid output format
*/
export function isValidFormat(format: string): format is OutputFormat {
return format === "rich" || format === "json" || format === "plain"
}
/**
* Parse output format from string, with validation
*/
export function parseOutputFormat(format: string | undefined): OutputFormat {
if (!format) {
return getDefaultFormat()
}
if (!isValidFormat(format)) {
throw new Error(`Invalid output format: ${format}. Valid options are: rich, json, plain`)
}
return format
}
/**
* Create an output formatter based on the specified format
*/
export function createFormatter(format: OutputFormat): OutputFormatter {
switch (format) {
case "json":
return createJsonFormatter()
case "plain":
return createPlainFormatter()
case "rich":
return createRichFormatter()
default:
// TypeScript exhaustiveness check
const _exhaustive: never = format
throw new Error(`Unknown output format: ${_exhaustive}`)
}
}
/**
* Create an output formatter from an optional format string.
* Uses default format if undefined or invalid.
*/
export function createFormatterFromOption(format: string | undefined): OutputFormatter {
return createFormatter(parseOutputFormat(format))
}
+84
View File
@@ -0,0 +1,84 @@
/**
* JSON output formatter - structured output for scripting/automation
* Each output is a single JSON line for easy parsing
*/
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
/**
* JSON output wrapper type
*/
interface JsonOutput {
type: "message" | "error" | "success" | "warn" | "info" | "table" | "list" | "tasks" | "keyValue" | "raw" | "code"
data: unknown
ts: number
}
/**
* JSON formatter implementation
*/
export class JsonFormatter implements OutputFormatter {
/**
* Output a JSON line to stdout
*/
private output(type: JsonOutput["type"], data: unknown): void {
const output: JsonOutput = {
type,
data,
ts: Date.now(),
}
console.log(JSON.stringify(output))
}
message(msg: ClineMessage): void {
this.output("message", msg)
}
error(err: Error | string): void {
const data = err instanceof Error ? { message: err.message, name: err.name, stack: err.stack } : { message: err }
this.output("error", data)
}
success(text: string): void {
this.output("success", { message: text })
}
warn(text: string): void {
this.output("warn", { message: text })
}
info(text: string): void {
this.output("info", { message: text })
}
table(data: Record<string, unknown>[], columns?: string[]): void {
this.output("table", { rows: data, columns: columns || (data.length > 0 ? Object.keys(data[0]) : []) })
}
list(items: string[]): void {
this.output("list", { items })
}
tasks(tasks: TaskInfo[]): void {
this.output("tasks", { tasks })
}
keyValue(data: Record<string, unknown>): void {
this.output("keyValue", data)
}
raw(text: string): void {
this.output("raw", { content: text })
}
code(code: any): void {
this.output("code", { code })
}
}
/**
* Create a JSON formatter instance
*/
export function createJsonFormatter(): OutputFormatter {
return new JsonFormatter()
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Plain text output formatter - no colors, no styling
* Suitable for piping to other commands or redirecting to files
*/
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
// Store reference to original stdout.write to bypass console filtering
const stdoutWrite = process.stdout.write.bind(process.stdout)
/**
* Plain text formatter implementation
*/
export class PlainFormatter implements OutputFormatter {
message(msg: ClineMessage): void {
const timestamp = new Date(msg.ts).toISOString()
const prefix = msg.type === "ask" ? "[?]" : "[>]"
const subtype = msg.say || msg.ask || ""
const subtypeStr = subtype ? ` (${subtype})` : ""
if (msg.text) {
console.log(`${prefix}${subtypeStr} ${msg.text}`)
}
if (msg.reasoning) {
console.log(`[thinking] ${msg.reasoning}`)
}
}
error(err: Error | string): void {
const message = err instanceof Error ? err.message : err
console.error(`ERROR: ${message}`)
}
success(text: string): void {
console.log(`OK: ${text}`)
}
warn(text: string): void {
console.warn(`WARN: ${text}`)
}
info(text: string): void {
console.log(`INFO: ${text}`)
}
table(data: Record<string, unknown>[], columns?: string[]): void {
if (data.length === 0) {
console.log("(no data)")
return
}
// Determine columns from first row if not specified
const cols = columns || Object.keys(data[0])
// Print header
console.log(cols.join("\t"))
// Print rows
for (const row of data) {
const values = cols.map((col) => String(row[col] ?? ""))
console.log(values.join("\t"))
}
}
list(items: string[]): void {
for (const item of items) {
console.log(`- ${item}`)
}
}
tasks(tasks: TaskInfo[]): void {
if (tasks.length === 0) {
console.log("No tasks found")
return
}
for (const task of tasks) {
const date = new Date(task.ts).toISOString().split("T")[0]
const status = task.completed ? "[done]" : "[active]"
const snippet = task.task.length > 50 ? task.task.substring(0, 47) + "..." : task.task
console.log(`${task.id}\t${date}\t${status}\t${snippet}`)
}
}
keyValue(data: Record<string, unknown>): void {
for (const [key, value] of Object.entries(data)) {
console.log(`${key}: ${String(value)}`)
}
}
raw(text: string): void {
stdoutWrite(text + "\n")
}
code(code: string): void {
stdoutWrite(code + "\n")
}
}
/**
* Create a plain text formatter instance
*/
export function createPlainFormatter(): OutputFormatter {
return new PlainFormatter()
}
+190
View File
@@ -0,0 +1,190 @@
/**
* Rich text output formatter - colorful terminal output with styling
* Uses chalk for ANSI color support
*/
import chalk from "chalk"
import type { ClineMessage, OutputFormatter, TaskInfo } from "./types.js"
// Store reference to original stdout.write to bypass console filtering
const stdoutWrite = process.stdout.write.bind(process.stdout)
/**
* Rich formatter implementation with colors and styling
*/
export class RichFormatter implements OutputFormatter {
message(msg: ClineMessage): void {
const timestamp = chalk.gray(new Date(msg.ts).toLocaleTimeString())
// Determine icon and color based on message type and subtype
let icon: string
let color: typeof chalk
if (msg.type === "ask") {
icon = chalk.yellow("?")
color = chalk.yellow
} else {
// Say messages
switch (msg.say) {
case "error":
icon = chalk.red("✗")
color = chalk.red
break
case "completion_result":
icon = chalk.green("✓")
color = chalk.green
break
case "tool":
icon = chalk.blue("🔧")
color = chalk.blue
break
case "command":
icon = chalk.cyan("$")
color = chalk.cyan
break
case "command_output":
icon = chalk.gray(">")
color = chalk.gray
break
case "api_req_started":
icon = chalk.magenta("→")
color = chalk.magenta
break
case "api_req_finished":
icon = chalk.magenta("←")
color = chalk.magenta
break
default:
icon = chalk.white("●")
color = chalk.white
}
}
// Output message text
if (msg.text) {
const subtypeLabel = msg.say || msg.ask
const label = subtypeLabel ? chalk.dim(`[${subtypeLabel}]`) : ""
console.log(`${icon} ${timestamp} ${label}`)
console.log(` ${color(msg.text)}`)
}
// Output reasoning in a distinct style
if (msg.reasoning) {
console.log(chalk.dim.italic(` 💭 ${msg.reasoning}`))
}
// Show partial indicator for streaming
if (msg.partial) {
console.log(chalk.dim(" ⋯ (streaming)"))
}
}
error(err: Error | string): void {
const message = err instanceof Error ? err.message : err
console.error(chalk.red.bold("✗ Error:"), chalk.red(message))
if (err instanceof Error && err.stack) {
console.error(chalk.dim(err.stack.split("\n").slice(1).join("\n")))
}
}
success(text: string): void {
console.log(chalk.green.bold("✓"), chalk.green(text))
}
warn(text: string): void {
console.warn(chalk.yellow.bold("!"), chalk.yellow(text))
}
info(text: string): void {
console.log(chalk.blue.bold("i"), chalk.blue(text))
}
table(data: Record<string, unknown>[], columns?: string[]): void {
if (data.length === 0) {
console.log(chalk.dim("(no data)"))
return
}
// Determine columns from first row if not specified
const cols = columns || Object.keys(data[0])
// Calculate column widths
const widths = cols.map((col) => {
const values = data.map((row) => String(row[col] ?? ""))
return Math.max(col.length, ...values.map((v) => v.length))
})
// Print header
const header = cols.map((col, i) => chalk.bold(col.padEnd(widths[i]))).join(" ")
console.log(header)
console.log(chalk.dim("─".repeat(header.length)))
// Print rows
for (const row of data) {
const values = cols.map((col, i) => String(row[col] ?? "").padEnd(widths[i]))
console.log(values.join(" "))
}
}
list(items: string[]): void {
for (const item of items) {
console.log(chalk.cyan(" •"), item)
}
}
tasks(tasks: TaskInfo[]): void {
if (tasks.length === 0) {
console.log(chalk.dim("No tasks found"))
return
}
console.log(chalk.bold("Tasks:\n"))
for (const task of tasks) {
const date = new Date(task.ts).toLocaleDateString()
const time = new Date(task.ts).toLocaleTimeString()
const status = task.completed ? chalk.green("✓ done") : chalk.yellow("◉ active")
// Truncate task text if too long
const maxLen = 60
const snippet = task.task.length > maxLen ? task.task.substring(0, maxLen - 3) + "..." : task.task
console.log(` ${chalk.bold(task.id)} ${chalk.dim(`(${date} ${time})`)} ${status}`)
console.log(` ${chalk.white(snippet)}`)
if (task.totalTokens || task.totalCost) {
const tokens = task.totalTokens ? `${task.totalTokens.toLocaleString()} tokens` : ""
const cost = task.totalCost ? `$${task.totalCost.toFixed(4)}` : ""
console.log(` ${chalk.dim([tokens, cost].filter(Boolean).join(" • "))}`)
}
console.log()
}
}
keyValue(data: Record<string, unknown>): void {
const maxKeyLen = Math.max(...Object.keys(data).map((k) => k.length))
for (const [key, value] of Object.entries(data)) {
const paddedKey = key.padEnd(maxKeyLen)
console.log(`${chalk.bold(paddedKey)} ${chalk.white(String(value))}`)
}
}
raw(text: string): void {
// Use stdout.write directly to bypass console filtering
// This is important for commands like `dump` that output JSON
// containing strings that would otherwise be filtered
stdoutWrite(text + "\n")
}
code(codeText: string): void {
console.log(chalk.green(codeText))
}
}
/**
* Create a rich text formatter instance
*/
export function createRichFormatter(): OutputFormatter {
return new RichFormatter()
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Output formatting types for the CLI
*/
/**
* Supported output formats
*/
export type OutputFormat = "rich" | "json" | "plain"
/**
* Cline message structure matching the extension's message format
*/
export interface ClineMessage {
/** Message type - ask requires user response, say is informational */
type: "ask" | "say"
/** Message text content */
text?: string
/** Unix epoch milliseconds timestamp */
ts: number
/** AI reasoning/thinking content */
reasoning?: string
/** Say message subtype */
say?:
| "text"
| "user_feedback"
| "user_feedback_diff"
| "error"
| "completion_result"
| "tool"
| "command"
| "command_output"
| "api_req_started"
| "api_req_finished"
| "api_req_retried"
/** Ask message subtype */
ask?:
| "followup"
| "command"
| "command_output"
| "completion_result"
| "tool"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "mistake_limit_reached"
| "auto_approval_max_req_reached"
/** Whether this is a partial/streaming message */
partial?: boolean
/** Attached image paths */
images?: string[]
/** Attached file paths */
files?: string[]
/** Last checkpoint hash for restore operations */
lastCheckpointHash?: string
/** Whether a checkpoint is currently checked out */
isCheckpointCheckedOut?: boolean
/** Whether operation is outside workspace */
isOperationOutsideWorkspace?: boolean
}
/**
* Task information for list/display operations
*/
export interface TaskInfo {
/** Unique task identifier */
id: string
/** Task creation timestamp */
ts: number
/** Initial task prompt/description */
task: string
/** Total tokens used in task */
totalTokens?: number
/** Total cost of task */
totalCost?: number
/** Whether task is completed */
completed?: boolean
}
/**
* Output formatter interface - all formatters must implement this
*/
export interface OutputFormatter {
/** Format and output a Cline message */
message(msg: ClineMessage): void
/** Format and output an error */
error(err: Error | string): void
/** Format and output a success message */
success(text: string): void
/** Format and output a warning */
warn(text: string): void
/** Format and output an info message */
info(text: string): void
/** Format and output tabular data */
table(data: Record<string, unknown>[], columns?: string[]): void
/** Format and output a list of items */
list(items: string[]): void
/** Format and output a task list */
tasks(tasks: TaskInfo[]): void
/** Format and output key-value pairs */
keyValue(data: Record<string, unknown>): void
code(codeText: string): void
/** Output raw text without formatting */
raw(text: string): void
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Path parser for @path syntax and file attachment handling
*
* Provides utilities for:
* - Parsing @path references from message text
* - Detecting image files by extension
* - Converting image files to base64 data URLs
*/
import fs from "fs"
import path from "path"
/**
* Supported image extensions (what Anthropic API accepts)
*/
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
/**
* MIME types for image extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
/**
* Result of parsing @path references from a message
*/
export interface ParsedAttachments {
/** Message with @paths removed */
cleanedMessage: string
/** Non-image file paths (absolute) */
files: string[]
/** Base64 data URLs for images */
images: string[]
/** Warnings for files that couldn't be processed */
warnings: string[]
}
/**
* Check if a file path is an image based on extension
*/
export function isImageFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase()
return IMAGE_EXTENSIONS.has(ext)
}
/**
* Convert an image file to a base64 data URL
*
* @param filePath - Path to the image file
* @returns Base64 data URL (data:image/type;base64,...)
* @throws Error if file doesn't exist or isn't a valid image type
*/
export function fileToBase64DataUrl(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const mimeType = IMAGE_MIME_TYPES[ext]
if (!mimeType) {
throw new Error(`Unsupported image format: ${ext}. Supported formats: ${Array.from(IMAGE_EXTENSIONS).join(", ")}`)
}
if (!fs.existsSync(filePath)) {
throw new Error(`Image file not found: ${filePath}`)
}
const buffer = fs.readFileSync(filePath)
const base64 = buffer.toString("base64")
return `data:${mimeType};base64,${base64}`
}
/**
* Parse @path references from a message string
*
* Supports:
* - @./relative/path - relative to cwd
* - @/absolute/path - absolute path
* - @path/without/dot - relative to cwd
*
* @paths must be preceded by whitespace or be at start of string
* @paths end at whitespace or end of string
*
* @param message - The message text to parse
* @param cwd - Current working directory for resolving relative paths
* @returns Parsed attachments with cleaned message, files, images, and warnings
*/
export function parseAtPaths(message: string, cwd: string): ParsedAttachments {
const files: string[] = []
const images: string[] = []
const warnings: string[] = []
// Regex to match @path patterns
// Must be at start or preceded by whitespace
// Path continues until whitespace or end of string
// Path must contain at least one character after @
const atPathRegex = /(?:^|\s)@([^\s@]+)/g
const matches: Array<{ fullMatch: string; path: string; index: number }> = []
let match: RegExpExecArray | null
while ((match = atPathRegex.exec(message)) !== null) {
const fullMatch = match[0]
const pathPart = match[1]
// Skip if path is empty or just whitespace
if (!pathPart || !pathPart.trim()) {
continue
}
matches.push({
fullMatch,
path: pathPart,
index: match.index,
})
}
// Process matches in reverse order so we can safely remove them from the message
let cleanedMessage = message
for (let i = matches.length - 1; i >= 0; i--) {
const { fullMatch, path: pathPart } = matches[i]
// Resolve the path
let absolutePath: string
if (path.isAbsolute(pathPart)) {
absolutePath = pathPart
} else {
absolutePath = path.resolve(cwd, pathPart)
}
// Check if file exists
if (!fs.existsSync(absolutePath)) {
warnings.push(`File not found: ${pathPart}`)
continue
}
// Check if it's a directory
if (fs.statSync(absolutePath).isDirectory()) {
warnings.push(`Cannot attach directory: ${pathPart}`)
continue
}
// Determine if it's an image or regular file
if (isImageFile(absolutePath)) {
try {
const dataUrl = fileToBase64DataUrl(absolutePath)
images.push(dataUrl)
// Remove the @path from the message (preserve leading whitespace if present)
const hasLeadingSpace = fullMatch.startsWith(" ") || fullMatch.startsWith("\t")
cleanedMessage = cleanedMessage.replace(fullMatch, hasLeadingSpace ? " " : "")
} catch (error) {
warnings.push(`Failed to read image: ${pathPart} - ${(error as Error).message}`)
}
} else {
files.push(absolutePath)
// Remove the @path from the message (preserve leading whitespace if present)
const hasLeadingSpace = fullMatch.startsWith(" ") || fullMatch.startsWith("\t")
cleanedMessage = cleanedMessage.replace(fullMatch, hasLeadingSpace ? " " : "")
}
}
// Clean up multiple spaces that may have been left
cleanedMessage = cleanedMessage.replace(/\s+/g, " ").trim()
return {
cleanedMessage,
files,
images,
warnings,
}
}
/**
* Process explicit file paths from CLI options
*
* Unlike parseAtPaths, this throws errors for missing files (strict mode)
*
* @param filePaths - Array of file paths from CLI options
* @param cwd - Current working directory for resolving relative paths
* @returns Object with files (paths) and images (base64 data URLs)
* @throws Error if any file doesn't exist
*/
export function processExplicitFiles(
filePaths: string[],
cwd: string,
): {
files: string[]
images: string[]
} {
const files: string[] = []
const images: string[] = []
for (const filePath of filePaths) {
// Resolve the path
let absolutePath: string
if (path.isAbsolute(filePath)) {
absolutePath = filePath
} else {
absolutePath = path.resolve(cwd, filePath)
}
// Check if file exists (strict - throw error)
if (!fs.existsSync(absolutePath)) {
throw new Error(`File not found: ${filePath}`)
}
// Check if it's a directory
if (fs.statSync(absolutePath).isDirectory()) {
throw new Error(`Cannot attach directory: ${filePath}`)
}
// Determine if it's an image or regular file
if (isImageFile(absolutePath)) {
const dataUrl = fileToBase64DataUrl(absolutePath)
images.push(dataUrl)
} else {
files.push(absolutePath)
}
}
return { files, images }
}
/**
* Process explicit image paths from CLI options
*
* @param imagePaths - Array of image paths from CLI options
* @param cwd - Current working directory for resolving relative paths
* @returns Array of base64 data URLs
* @throws Error if any file doesn't exist or isn't a valid image
*/
export function processExplicitImages(imagePaths: string[], cwd: string): string[] {
const images: string[] = []
for (const imagePath of imagePaths) {
// Resolve the path
let absolutePath: string
if (path.isAbsolute(imagePath)) {
absolutePath = imagePath
} else {
absolutePath = path.resolve(cwd, imagePath)
}
// Check if file exists (strict - throw error)
if (!fs.existsSync(absolutePath)) {
throw new Error(`Image file not found: ${imagePath}`)
}
// Check if it's actually an image
if (!isImageFile(absolutePath)) {
throw new Error(
`Not a supported image format: ${imagePath}. Supported formats: ${Array.from(IMAGE_EXTENSIONS).join(", ")}`,
)
}
const dataUrl = fileToBase64DataUrl(absolutePath)
images.push(dataUrl)
}
return images
}

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