mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f463496c | |||
| 72562ea74e | |||
| 544e3aa240 | |||
| beb3ad78dc | |||
| ee1d4b4dcf | |||
| f2eda58c70 | |||
| 4afc973f7d | |||
| 5fe6c9a8ce | |||
| 901d1b5c97 | |||
| c139f7a4d5 | |||
| 07593bb42a | |||
| fd21c314c1 | |||
| 32ca1cad9a | |||
| 852c65b70c | |||
| 70f0e8d548 | |||
| 5accd88d73 | |||
| 5b29be63b8 | |||
| 9dea336ced | |||
| 697f801937 | |||
| 6be35bfcea | |||
| 5a91800b6c | |||
| dacadbaae0 | |||
| 8d020e89e6 | |||
| c6dbc8bcb0 | |||
| 1862f15955 | |||
| f6a9a02500 | |||
| 10af2439be | |||
| 9a8fbf9852 | |||
| c5657a14bb | |||
| 955ae8df7a | |||
| 9405419efe | |||
| f53dcb3096 | |||
| afa32bf801 | |||
| 2d2d9d829a | |||
| 27a1b3da8a | |||
| f5c8cd4384 | |||
| fddabb6b8d | |||
| 071f32ec92 | |||
| 9bdb8a9362 | |||
| 2d994530fd | |||
| e477f8fa04 | |||
| 36b0baec81 | |||
| a0faf7c677 | |||
| 1dcf356f98 | |||
| 71d795eec8 | |||
| 792b9e89a1 | |||
| 10197b038d | |||
| 9390d3f933 | |||
| 5df470bf48 | |||
| 034c4342d1 | |||
| 349295ab2c | |||
| 841402c178 | |||
| e52a052c81 | |||
| 7a91a9be2e | |||
| 93a494d009 | |||
| 2bd21f8a45 | |||
| 2f33f71ebd | |||
| dec10aaec3 | |||
| c09705a45f | |||
| 73058c871a | |||
| 03211f1364 | |||
| 65e9727c65 | |||
| 884fbfb21e | |||
| 0c880c7bb1 | |||
| a915122c6d | |||
| 835ed94736 | |||
| 00ca3d13fa | |||
| 3f3a87aed9 | |||
| 7a0f11837e | |||
| 03d2d01eed | |||
| db1b1c45bd | |||
| a4131e57d8 | |||
| 683dd9a741 | |||
| 0d437f71b0 | |||
| fc77c0faea | |||
| 12c85c4233 | |||
| d6b7a1ab41 | |||
| 3926e7b404 | |||
| 5ba4314b9a | |||
| 9ccad7f764 | |||
| 3562f54dbf | |||
| d427d5d76a | |||
| 072e2887b0 | |||
| 9c71a6f021 | |||
| 7627a382aa | |||
| 978c633c90 | |||
| e807b520e0 | |||
| f3a3f30db5 | |||
| f1c7934064 | |||
| 1d42da5248 | |||
| 7f3974a827 | |||
| d40ab56aff | |||
| bbdf445db7 | |||
| d992a3bf21 | |||
| ace95988f8 | |||
| 54726f1677 | |||
| 6308fef0a9 | |||
| b3fc79b8ce | |||
| ff05ec3bbe | |||
| bde7049c01 | |||
| 7cd06744ad | |||
| c88d3238cf | |||
| 852ca2348f | |||
| 7091ccf2c7 | |||
| ad6c33ac5b | |||
| 71e312e92a | |||
| 5903840f79 | |||
| 0c677b63db | |||
| b741135f85 | |||
| 2eab216815 | |||
| e57174eec8 | |||
| ad4631c682 | |||
| aae23e8685 | |||
| 0ff27591d5 | |||
| 23127f22de | |||
| 57ed14d1b1 | |||
| a769585406 | |||
| c44b29b002 | |||
| bb4e397a51 | |||
| 9824d8d476 | |||
| a46c5288ca | |||
| 91b947de69 | |||
| 7b25a21b26 | |||
| 1d1071dcf5 | |||
| 8d07b7d6cb | |||
| aacc69a558 | |||
| a27bedffb6 | |||
| bbeecefe84 | |||
| 50b57f472f | |||
| 0e7e0099cd | |||
| 44cbfe9d9c | |||
| 4ecccf5105 | |||
| 6d29bc6551 | |||
| 820057308a | |||
| 7355f7e3b9 | |||
| 156f18f7b2 | |||
| 6129caa423 | |||
| ffa4785f43 | |||
| 92ec126dca | |||
| c4c8b16afb | |||
| 2eed79dcd7 | |||
| d39a53feed | |||
| cae9bff416 | |||
| 36618fb327 | |||
| a335b5cb3e | |||
| bce71b4448 | |||
| f6d2b4d9ac | |||
| 911fcfda47 | |||
| 55569efb74 | |||
| 18561ed59d | |||
| fa7265fa33 | |||
| bf96223639 | |||
| c489f79508 | |||
| 6ccd662a1f | |||
| a2fa4d3b92 | |||
| c8f81374a3 | |||
| eb25634bd4 | |||
| 1753d9dc15 | |||
| a562868cb9 | |||
| bf5265758f | |||
| 469752a201 | |||
| 8fb2e8a3aa | |||
| aef32f52f9 | |||
| 2baf966db7 | |||
| 152ba674da | |||
| 12f5dc2e9e | |||
| 28d806a4e4 | |||
| e92c7de8c0 | |||
| 718e5b53f6 | |||
| e484534bcb | |||
| 2822723495 | |||
| b5c0cc18d8 | |||
| fb04a9bd15 | |||
| 6def83a5d9 | |||
| fc3d986d05 | |||
| 520ddb83bb | |||
| d96c5d4b40 | |||
| e6cbae0edc | |||
| 76bd0926e6 | |||
| 819f9ce00d | |||
| fd8cecddd5 | |||
| a502bd8653 | |||
| d48d5ee74d | |||
| cd320ea01f |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": minor
|
||||
---
|
||||
|
||||
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add /q command to quit CLI
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add Additional Markdown Formatting in CLI
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: resolve "Could not find the file context" error in Explain Changes comment replies
|
||||
|
||||
When clicking a line to start a discussion in the Explain Changes diff view, replies would
|
||||
intermittently fail with "Error: Could not find the file context". This happened because
|
||||
the reply handler and the `onCommentStart` callback were using a strict `absolutePath`-only
|
||||
match to look up files in `changedFiles`, while the VS Code comment controller may return
|
||||
paths in different formats (relative vs. absolute, different separators on Windows, etc.).
|
||||
|
||||
Fixed by adding a `relativePath` fallback in both lookup sites, making them consistent with
|
||||
the already-correct logic in `streamAIExplanationComments`.
|
||||
|
||||
Fixes #9382
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix: clear all OCA secrets on auth refresh failure to prevent re-auth loop
|
||||
|
||||
When OCA (Oracle Code Assist) token refresh fails with 400 invalid_grant or 401,
|
||||
the stale secrets were not fully cleared from storage. The `clearAuth()` method
|
||||
only cleared `ocaApiKey` and `ocaRefreshToken`, leaving legacy secrets
|
||||
`ocaAccessToken` and `ocaTokenSet` (set by older Cline versions) in VS Code's
|
||||
secret storage. These stale secrets caused every subsequent re-auth attempt to
|
||||
fail in a loop, requiring manual SQLite deletion to recover.
|
||||
|
||||
Fix:
|
||||
- Added `ocaAccessToken` and `ocaTokenSet` to `SecretKeys` in `state-keys.ts`
|
||||
- Updated `OcaAuthProvider.clearAuth()` to clear all 4 OCA secrets
|
||||
|
||||
Fixes #9567
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Use JSON_SCHEMA for yaml.load to prevent unsafe deserialization from untrusted sources
|
||||
@@ -1,4 +0,0 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add automatic retries (up to 3 attempts) for smoke test CI jobs to reduce flaky failures
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
add focus ring on action buttons
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
fix acp auth check so acp mode can be used with more providers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update SambaNova Provider models list and add temperature for models
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PostToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PostToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PostToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "PreToolUse running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "PreToolUse response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "PreToolUse hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskCancel running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskCancel response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskCancel hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskResume running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskResume response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskResume hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "TaskStart running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "TaskStart response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "TaskStart hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "UserPromptSubmit running inside local cline/.clinerules/hooks/ directory"
|
||||
|
||||
input=$(cat)
|
||||
echo $input | jq .
|
||||
|
||||
for i in {1..5}; do
|
||||
sleep 1
|
||||
echo "$i"
|
||||
done
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"cancel": false,
|
||||
"contextModification": "UserPromptSubmit response from the local cline/.clinerules/hooks/ directory.",
|
||||
"errorMessage": "UserPromptSubmit hook custom errorMessage"
|
||||
}
|
||||
EOF
|
||||
@@ -1,4 +1,6 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -26,6 +26,12 @@ body:
|
||||
placeholder: 'e.g., 1.2.3'
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: beta
|
||||
attributes:
|
||||
label: Beta version
|
||||
options:
|
||||
- label: I am using a beta version of Cline
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copilot Instructions for Cline
|
||||
|
||||
This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge and nuanced patterns.
|
||||
|
||||
## Architecture
|
||||
- **Core** (`src/`): `extension.ts` → `WebviewProvider` → `Controller` (single source of truth) → `Task` (agent loop).
|
||||
- **Webview** (`webview-ui/`): React/Vite app. State via `ExtensionStateContext.tsx`, synced through message passing.
|
||||
- **CLI** (`cli/`): React Ink terminal UI sharing core logic. Update CLI when changing webview features.
|
||||
- **Communication**: Protobuf-defined gRPC-like protocol over VS Code message passing. Schemas in `proto/`.
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `npm run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
|
||||
## Adding API Providers (silent failure risk)
|
||||
Three proto conversion updates are **required** or the provider silently resets to Anthropic:
|
||||
1. `proto/cline/models.proto` — add to `ApiProvider` enum.
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts`.
|
||||
3. `convertProtoToApiProvider()` in the same file.
|
||||
|
||||
Also update: `src/shared/api.ts`, `src/shared/providers/providers.json`, `src/core/api/index.ts`, `webview-ui/.../providerUtils.ts`, `webview-ui/.../validate.ts`, `webview-ui/.../ApiOptions.tsx`, and `cli/src/components/ModelPicker.tsx`.
|
||||
|
||||
For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/model-utils.ts` and set `apiFormat: ApiFormat.OPENAI_RESPONSES` on models.
|
||||
|
||||
## Adding Tools to System Prompt (5+ file chain)
|
||||
1. Add enum to `ClineDefaultTool` in `src/shared/tools.ts`.
|
||||
2. Create definition in `src/core/prompts/system-prompt/tools/` (export `[GENERIC]` minimum).
|
||||
3. Register in `src/core/prompts/system-prompt/tools/init.ts`.
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
- `src/core/prompts/commands.ts` — system prompt integration.
|
||||
- `webview-ui/src/utils/slash-commands.ts` — webview autocomplete.
|
||||
|
||||
## Conventions
|
||||
- **Paths**: Always use `src/utils/path` helpers (`toPosixString`) for cross-platform compatibility.
|
||||
- **Logging**: `src/shared/services/Logger.ts`.
|
||||
- **Feature flags**: See PR #7566 as reference pattern.
|
||||
@@ -0,0 +1,83 @@
|
||||
name: CLI TUI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
name: CLI TUI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run cli:build
|
||||
|
||||
- name: Run TUI Tests
|
||||
id: tui_tests
|
||||
run: |
|
||||
npm run test:e2e:cli:tui 2>&1 | tee tui-test-output.log
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "tui_exit_code=$exit_code" >> $GITHUB_OUTPUT
|
||||
exit $exit_code
|
||||
|
||||
- name: Write failure summary
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
run: |
|
||||
echo "## ❌ CLI TUI Tests Failed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Step outcome:** \`${{ steps.tui_tests.outcome }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Test Output" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f tui-test-output.log ]; then
|
||||
cat tui-test-output.log >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "(no test output captured — process may have been killed before output was flushed)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Debugging" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **TUI traces** are attached as artifacts below — download and inspect them to see terminal state at the point of failure." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **To view a trace replay/Run a TUI Trace: ** run \`npx tui-test show-trace path/to/trace/file\` in your terminal" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Full test log** is also attached as an artifact." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tests run with \`retries: 2\` so any failure shown is a consistent failure, not a flake." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload TUI traces
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-traces
|
||||
path: tests/e2e/cli/tui-traces/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload test log
|
||||
if: always() && steps.tui_tests.outcome != 'success' && steps.tui_tests.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tui-test-log
|
||||
path: tui-test-output.log
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -51,3 +51,15 @@ jobs:
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if beta version checkbox is checked
|
||||
if (body.includes('- [X] I am using a beta version of Cline') || body.includes('- [x] I am using a beta version of Cline')) {
|
||||
if (!labels.includes('beta')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['beta']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,21 @@ jobs:
|
||||
echo ""
|
||||
echo "📦 Install with: npm install -g cline"
|
||||
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline CLI v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline CLI v${{ steps.version.outputs.version }}*"
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}|View on npm>"
|
||||
|
||||
@@ -31,8 +31,10 @@ jobs:
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
env:
|
||||
FORCE_PUBLISH: ${{ inputs.force_publish }}
|
||||
run: |
|
||||
if [ "${{ inputs.force_publish }}" = "true" ]; then
|
||||
if [ "$FORCE_PUBLISH" = "true" ]; then
|
||||
echo "force_publish enabled, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
|
||||
@@ -30,7 +30,11 @@ permissions:
|
||||
pull-requests: write # Required by nested reusable test workflow
|
||||
|
||||
jobs:
|
||||
cli-tui-tests:
|
||||
uses: ./.github/workflows/cli-tui-tests.yml
|
||||
|
||||
publish-main:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
@@ -44,6 +48,7 @@ jobs:
|
||||
confirm_publish: ${{ github.event.inputs.confirm_publish }}
|
||||
|
||||
publish-nightly:
|
||||
needs: cli-tui-tests
|
||||
if: |
|
||||
github.repository == 'cline/cline' && (
|
||||
github.event_name == 'schedule' ||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: "Publish SDK Nightly Release"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
# Keep the publish source pinned to one reviewed branch instead of accepting arbitrary refs.
|
||||
SDK_NIGHTLY_REF: dpc/sdk-migration-simpler-login
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Cline (Nightly SDK) Extension
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted SDK nightly branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.SDK_NIGHTLY_REF }}
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish SDK nightly extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
@@ -1,8 +1,6 @@
|
||||
name: "Publish Nightly Release"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -24,6 +22,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Check for recent commits
|
||||
run: |
|
||||
@@ -49,7 +49,16 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Publish Extension as Pre-release
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
|
||||
+109
-16
@@ -11,8 +11,13 @@ on:
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
auto_create_tag_from_main:
|
||||
description: "Auto-create and push the provided tag from the tested main commit (recommended)"
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
tag:
|
||||
description: "Enter existing tag to publish (e.g., v3.1.2)"
|
||||
description: "Tag to publish (required in both modes, e.g., v3.1.2)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -35,9 +40,70 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.tag }}
|
||||
AUTO_CREATE: ${{ github.event.inputs.auto_create_tag_from_main }}
|
||||
run: |
|
||||
TESTED_SHA="${{ github.sha }}"
|
||||
WORKFLOW_REF="${{ github.ref }}"
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "Error: tag input is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: tag must match vX.Y.Z (optionally with -suffix or .suffix)"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
|
||||
git fetch origin main --tags
|
||||
|
||||
if [[ "$AUTO_CREATE" == "true" ]]; then
|
||||
if [[ "$WORKFLOW_REF" != "refs/heads/main" ]]; then
|
||||
echo "Error: auto-create mode requires dispatching from main (current ref: $WORKFLOW_REF)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Auto-create enabled. Using tested workflow SHA: $TESTED_SHA"
|
||||
|
||||
if ! git merge-base --is-ancestor "$TESTED_SHA" origin/main; then
|
||||
echo "Error: tested SHA $TESTED_SHA is not on origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at tested SHA ($TESTED_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at tested SHA. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$TESTED_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from tested SHA $TESTED_SHA."
|
||||
fi
|
||||
else
|
||||
if ! git show-ref --verify --quiet "$TAG_REF"; then
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG'."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -59,20 +125,24 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate Tag
|
||||
id: validate_tag
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using existing tag: $TAG"
|
||||
|
||||
# Verify the tag exists
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Error: Tag '$TAG' does not exist in the repository"
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
echo "Tag '$TAG' validated successfully"
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
@@ -88,11 +158,12 @@ 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 }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
@@ -103,7 +174,7 @@ jobs:
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.validate_tag.outputs.tag }}"
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -119,12 +190,34 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.validate_tag.outputs.tag }}
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.validate_tag.outputs.tag }}
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }}*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
|
||||
@@ -46,6 +46,8 @@ jobs:
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -81,6 +83,13 @@ jobs:
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .vscode-test
|
||||
key: vscode-test-runtime-${{ runner.os }}-${{ env.VSCODE_TEST_VERSION }}
|
||||
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
@@ -106,7 +115,21 @@ jobs:
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: npm run test:integration
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "Running extension integration tests (attempt ${attempt}/3)"
|
||||
if npm run test:integration; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "Extension integration tests failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Extension integration tests failed; retrying after short delay"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_tests
|
||||
|
||||
@@ -20,7 +20,8 @@ jobs:
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains'))
|
||||
contains(github.event.comment.body, '/test-jetbrains') &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
|
||||
@@ -51,3 +51,8 @@ test-results
|
||||
|
||||
# Smoke test results (generated)
|
||||
evals/smoke-tests/results/
|
||||
|
||||
.tui-test
|
||||
secrets.json
|
||||
tui-traces
|
||||
tests/**/cache
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts"
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
|
||||
|
||||
export default defineConfig({
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
@@ -12,7 +13,7 @@ export default defineConfig({
|
||||
require: ["./test-setup.js"],
|
||||
},
|
||||
workspaceFolder: "test-workspace",
|
||||
version: "stable",
|
||||
version: vscodeTestVersion,
|
||||
extensionDevelopmentPath: path.resolve("./"),
|
||||
launchArgs: ["--disable-extensions"],
|
||||
})
|
||||
|
||||
+226
@@ -1,5 +1,231 @@
|
||||
# Changelog
|
||||
|
||||
## [3.82.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore VS Code foreground terminal support and settings.
|
||||
- Add latest OpenAI, SAP AI Core, and Z AI models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix hook template JSON escaping.
|
||||
- Improve ripgrep file search error handling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove hardcoded model lists from docs.
|
||||
|
||||
## [3.81.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.5 model support for OpenAI Codex subscription users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove hardcoded "What’s New" fallback items in webview; only remote-configured welcome banners are shown.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve cline-core memory diagnostics used by the extension runtime:
|
||||
- enable near-heap-limit heap snapshots
|
||||
- add periodic memory usage logging
|
||||
- log discovered heap snapshots on abnormal exits for easier OOM debugging
|
||||
|
||||
## [3.80.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Wire up remote `globalSkills` from enterprise remote config with full UI, toggle support, and system prompt integration — enterprise-managed skills now appear under a dedicated "Enterprise Skills" section and support `alwaysEnabled` enforcement
|
||||
- Onboarding flow now uses dynamically fetched recommended models instead of a hardcoded list, with a fallback to the welcome view on failure
|
||||
- Add dedicated "Quota Exceeded" error message in the chat error UI when Cline account spend caps are hit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OOM crashes during long conversations by setting `--max-old-space-size=8192` for the cline-core Node.js process (was defaulting to ~2 GB)
|
||||
- Show detailed error information in the chat error row instead of a generic caught error message
|
||||
- Update `axios` to 1.15.0 across all packages
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove foreground terminal mode — all task command execution now defaults to background mode, removing the VS Code integrated terminal dependency and related settings UI
|
||||
- Remove old hardcoded announcement banners
|
||||
|
||||
## [3.79.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 4.7 model support
|
||||
- Add Azure Blob Storage as a storage provider
|
||||
- Add `globalSkills` to remote config
|
||||
- Inline value reuse in user-level remote-config discovery
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix cache reflection for Cline and Vercel API handlers
|
||||
- Fix stuck `command_output` ask when terminal command ends unexpectedly
|
||||
- Add `use_subagents` to system prompt for GLM, Hermes, and XS models
|
||||
- Fix action injection security risk
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove deprecated evals tool
|
||||
|
||||
## [3.78.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a dedicated "Spend Limit Reached" error UI when spend caps are hit
|
||||
- Docs updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show actual `read_file` line ranges in chat UI
|
||||
|
||||
## [3.77.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add "Lazy Teammate Mode" experimental toggle
|
||||
- `read_file` tool now supports chunked reading for targeted file access
|
||||
|
||||
### Fixed
|
||||
|
||||
- Exclude `new_task` tool from system prompt in yolo/headless mode
|
||||
- Fix Kanban demo video formatting
|
||||
|
||||
### Changed
|
||||
|
||||
- Polish `Notification` hook functionality
|
||||
|
||||
## [3.76.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Cline Kanban launch modal in webview; CLI now launches Kanban by default with a migration view
|
||||
- Add toggle to disable feature tips in chat
|
||||
- Add repeated tool call loop detection to prevent infinite loops wasting tokens
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix CLI Kanban spawn on Windows by enabling shell mode for `npx.cmd`
|
||||
|
||||
## [3.75.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize flaky hooks tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove example hooks in favor of reading the docs
|
||||
|
||||
## [3.74.0]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [3.73.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models
|
||||
- Improved parallel tool calling support for OpenRouter and Cline providers
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code Provider: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- Tool handlers (`read_file`, `list_files`, `list_code_definition_names`, `search_files`) now return graceful errors instead of crashing
|
||||
|
||||
## [3.72.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Anthropic Opus 4.6 fast mode variants
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved native tool placeholder interpolation in prompts
|
||||
- Gemini: capped Flash output tokens to 8192 across providers
|
||||
- Fixed Windows unit test path normalization
|
||||
- Fixed flaky hooks tests on Windows
|
||||
- Bedrock: handle thinking and redacted_thinking blocks correctly in message conversion and streaming
|
||||
- Prevent crash when `list_files` or `list_code_definition_names` receives a file path
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated Jupyter Notebook GIFs
|
||||
- Markdown image loading now requires user consent
|
||||
- Added `.github/copilot-instructions.md` for coding agents
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [3.71.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GPT-5.4 models for ChatGPT subscription users
|
||||
- Hooks: Added a `Notification` hook for attention and completion boundaries
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle streamable HTTP MCP reconnects more reliably after disconnects
|
||||
|
||||
## [3.70.0]
|
||||
|
||||
### Added
|
||||
|
||||
- New Cline API docs: Getting Started, Auth, Chat Completions, Models, Errors, and SDK Examples
|
||||
- Hook payloads now include `model.provider` and `model.slug`
|
||||
- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve subagent context compaction logic
|
||||
- Subagent stream retry delay increased to reduce noise from transient failures
|
||||
- State serialization errors are now caught and logged instead of crashing
|
||||
- Removed incorrect `max_tokens` from OpenRouter requests
|
||||
|
||||
### Changed
|
||||
|
||||
- Windows test cleanup now retries on locked files and applies per-test timeouts
|
||||
- Updated hooks docs
|
||||
|
||||
|
||||
## [3.69.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add `User-Agent` header to requests sent to the Cline backend
|
||||
- Add default auto-tag workflow for publish release flow
|
||||
- Show Cline SDK docs on the Cline page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Retry nested git restore and prevent silent `.git_disabled` leftovers in checkpoints
|
||||
- Prevent Chinese filename escaping in diff view
|
||||
- Trigger auto-compaction on OpenRouter context overflow errors
|
||||
- Restore GPT-OSS native file editing on OpenAI-compatible models
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Cline SDK docs
|
||||
- Improve hooks support for Windows PowerShell
|
||||
|
||||
## [3.68.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -3,11 +3,6 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
</sub></div>
|
||||
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
+3
-5
@@ -8,9 +8,7 @@ We actively patch only the most recent minor release of Cline. Older versions re
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
To report a security issue, please submit your report through our [Bugcrowd Vulnerability Disclosure Program](https://bugcrowd.com/engagements/clinebot-vdp-ess). Bugcrowd will manage communication and triage on our behalf.
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
@@ -18,10 +16,10 @@ When reporting, please include:
|
||||
- Steps to reproduce or a proof of concept
|
||||
- Any logs, stack traces, or screenshots that might help us understand the problem
|
||||
|
||||
We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private.
|
||||
Please keep the details private until a resolution has been reached.
|
||||
|
||||
## Escalation
|
||||
|
||||
If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot.
|
||||
If you are unable to submit through Bugcrowd, you may send an email to security@cline.bot.
|
||||
|
||||
Thank you for helping us keep Cline users safe.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 512 535">
|
||||
<!-- Generator: Adobe Illustrator 29.8.5, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st0" d="M500.6,300.5c-9-20.7-17.9-41.4-26.9-62.1-.7-2-.3-4.4-.3-6.4.4-9,1.1-18,1.4-27,2.8-28.4-6.5-58-25.2-79.6-15.1-18.1-36.6-30.7-59.6-35.5-8.1-1.8-16.6-1.6-25-2.1-10-.7-20-1-30-1.7,2-11.9,1-24.1-3.7-35.3-5.8-14.1-16.8-25.9-30.6-32.5-14.4-7-31.5-8.2-46.7-3.1-16,5.2-29.5,17-36.8,32.1-4.9,10-6.8,21.2-6.1,32.2-19.7-1-39.4-2.2-59.1-3.1-26.8.5-53,11.7-72,30.6-20.2,19.5-31.7,47-32.3,75-.5,9.3-1,18.7-1.5,28-.2,2.1,0,4.1-1.2,6-9.8,16.8-19.5,33.7-29.4,50.6-2.2,4.1-4.9,8-6.6,12.3-2,5.7-1.2,12.2,1.3,17.6,8.9,19.5,17.6,39.2,26.5,58.7.8,1.9,1.5,3.7,1.3,5.8-.6,10.3-1.1,20.7-1.7,31-1.5,21.2,3,42.6,13.5,61.1,8.8,15.8,21.6,29.4,37.1,38.9,13.9,8.7,29.7,13.9,46,15.4,72,3.9,144,7.7,216,11.5,20.1,1.8,40.8-2.8,58.5-12.5,18.8-10.1,34.2-26,44.1-44.9,6.5-12.6,10.5-26.4,11.7-40.5.7-12.4,1.2-24.7,2-37.1,0-3.3,1.9-5.5,3.3-8.2,6.6-11.8,13.5-23.4,20.1-35.2,3.7-6.9,8.1-13.4,11.6-20.4,3.2-6.1,3.2-13.5.3-19.7ZM218.5,316.5c-9.7,7.1-21.3,12.3-33.5,12.5-17.6,1-35.1-5.3-49-16-4.6-3.2-8.1-7.5-9.6-13,0-1.8-.7-3.6,1.7-3.8,4,1,7.9,2.6,12,3.5,22.8,5.6,47.6,5.9,71,4.8,6.5-.2,13-1.3,19.5-.9-2.7,5.6-7.1,9.2-12,12.9ZM276,449.7c-14,.5-28,.1-42-.2-2.1,0-4.3,0-6.4-.4-.9-2.1.6-3.2,1.7-4.8,4.8-5.9,11-11,18.7-12.4,8.4-1.6,16.5,1.2,23.5,5.5,4.7,3,9.2,6.3,12.6,10.8-2.6,1.1-5.3,1.4-8.1,1.4ZM390.4,319.4c-16.4,14.2-38.8,21.8-60.4,18.4-13.2-1.6-24.7-8.6-34.1-17.7-3-3-6.2-6.5-8.1-10.4.5-1,1.2-1.6,2.2-1.6,2.8-.2,5.7.7,8.5,1.1,16,2.9,32.3,4.9,48.5,5.5,14.3.4,28.2-.2,42.2-3.6,2.2-.6,3.7-.3,5.8.5-1.1,2.9-2.2,5.7-4.6,7.8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,189 @@
|
||||
# cline
|
||||
|
||||
## [2.18.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore foreground terminal support and settings.
|
||||
- Add latest OpenAI, SAP AI Core, and Z AI models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix hook template JSON escaping.
|
||||
- Improve ripgrep file search error handling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove hardcoded model lists from docs.
|
||||
|
||||
## [2.17.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.5 model support for OpenAI Codex subscription users.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve `cline-core` runtime memory diagnostics used by CLI:
|
||||
- enable near-heap-limit heap snapshots
|
||||
- add periodic memory usage logging
|
||||
- log discovered heap snapshots on abnormal exits for easier OOM debugging
|
||||
|
||||
## [2.16.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Wire up remote `globalSkills` from enterprise remote config with full toggle support and system prompt integration — enterprise-managed skills now support `alwaysEnabled` enforcement
|
||||
- Add dedicated "Quota Exceeded" error message when Cline account spend caps are hit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix OOM crashes during long conversations by setting `--max-old-space-size=8192` for the cline-core Node.js process (was defaulting to ~2 GB)
|
||||
- Show detailed error information instead of a generic caught error message
|
||||
- Update `axios` to 1.15.0 across all packages
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove dead ACP terminal setter stubs as part of foreground terminal mode removal
|
||||
|
||||
## [2.15.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 4.7 model support
|
||||
- Inline value reuse in user-level remote-config discovery
|
||||
- Add `globalSkills` to remote config
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stabilize Windows CI test path handling
|
||||
|
||||
## [2.14.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Simplify unified `cline update` flow for `cline` and `kanban`
|
||||
- Docs updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update Kanban migration view copy
|
||||
|
||||
## [2.12.0]
|
||||
|
||||
### Added
|
||||
|
||||
- `read_file` tool now supports chunked reading for targeted file access
|
||||
|
||||
### Fixed
|
||||
|
||||
- Exclude `new_task` tool from system prompt in yolo/headless mode
|
||||
|
||||
### Changed
|
||||
|
||||
- Polish `Notification` hook functionality
|
||||
|
||||
## [2.9.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Latency improvements for remote workspaces
|
||||
|
||||
## [2.8.2]
|
||||
|
||||
### Fixed
|
||||
- Use `kanban@latest` in `cline kanban` to always fetch the newest version
|
||||
|
||||
## [2.8.1]
|
||||
|
||||
### Added
|
||||
- Implement dynamic free model detection for Cline API
|
||||
- Add file read deduplication cache to prevent repeated reads
|
||||
- Add feature tips tooltip during thinking state
|
||||
|
||||
### Fixed
|
||||
- Fix flaky CLI Enter-key handling across Windows/test environments
|
||||
- Replace error message when not logged in to Cline
|
||||
- Align ClineRulesToggleModal padding with ServersToggleModal
|
||||
- Skip WebP for GLM and Devstral models running through llama.cpp
|
||||
- Respect user-configured context window in LiteLLM getModel()
|
||||
- Honor explicit model IDs outside static catalog in W&B provider
|
||||
- Add missing Fireworks serverless models and pricing
|
||||
|
||||
## [2.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added W&B Inference by CoreWeave as a new API provider with 17 models including DeepSeek-V3.1, Llama 4, and Qwen3-Coder
|
||||
- Added CLI TUI end-to-end test suite
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude Code: handle rate limit events, empty content arrays, error results, and unknown content types without crashing
|
||||
- CLI: `/q` and `/exit` slash commands now execute immediately on Enter without requiring the slash menu to be visible
|
||||
- CLI: slash command filtering now prioritizes exact and prefix matches over fuzzy matches
|
||||
|
||||
## [2.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added MCP add shortcuts for stdio and HTTP servers
|
||||
- Added `--continue` for the current directory
|
||||
- Added `--auto-condense` flag for AI-powered context compaction
|
||||
- Added `--hooks-dir` flag for runtime hook injection
|
||||
- Enabled error autocapture
|
||||
- Prompt rules now include test verification guidance and make `CLI_RULES` language-agnostic
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed remount behavior so TUI remounts only on width resize
|
||||
- Fixed startup prompt replay on resize remount
|
||||
- Fixed task flags so they are applied before the welcome TUI mounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Hooks: reintroduced feature toggle
|
||||
|
||||
## [2.6.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GPT-5.4 models for ChatGPT subscription users
|
||||
- Hooks: Added a `Notification` hook for attention and completion boundaries
|
||||
- Added `--hooks-dir` CLI flag for runtime hook injection
|
||||
- Added `--auto-approve-all` CLI flag for interactive mode
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle streamable HTTP MCP reconnects more reliably
|
||||
|
||||
## [2.6.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Hook payloads now include `model.provider` and `model.slug`
|
||||
- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve subagent context compaction logic
|
||||
- Subagent stream retry delay increased to reduce noise from transient failures
|
||||
- State serialization errors are now caught and logged instead of crashing
|
||||
- Removed incorrect `max_tokens` from OpenRouter requests
|
||||
|
||||
## [2.5.2]
|
||||
|
||||
### Added
|
||||
|
||||
- Added Windows PowerShell support for hooks (execution, resolution, and management), improving hook behavior on Windows for CLI and shared core workflows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored GPT-OSS native file editing for OpenAI-compatible models used through shared core tooling.
|
||||
- Improved OpenRouter context overflow error handling so auto-compaction triggers correctly for wrapped 400 errors.
|
||||
- Hardened checkpoint recovery by retrying nested git restore and preventing silent `.git_disabled` leftovers.
|
||||
- Added a User-Agent header for requests to the Cline back-end to improve request handling consistency.
|
||||
|
||||
## [2.5.1]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -186,6 +186,7 @@ const buildEnvVars: Record<string, string> = {
|
||||
const buildTimeEnvs = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"ENABLE_ERROR_AUTOCAPTURE",
|
||||
"POSTHOG_TELEMETRY_ENABLED",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
|
||||
@@ -162,6 +162,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
|
||||
|
||||
**-T**, **\--taskId** *id* : Resume an existing task by ID instead of starting a new one. The prompt becomes an optional follow-up message.
|
||||
|
||||
**\--continue** : Resume the most recent task from the current working directory instead of starting a new one.
|
||||
|
||||
# JSON OUTPUT FORMAT
|
||||
|
||||
When using **\--json**, each message is output as a JSON object with these fields:
|
||||
@@ -268,6 +270,9 @@ cline -T abc123def
|
||||
# Resume a task with a follow-up message
|
||||
cline -T abc123def "Now add unit tests for the changes"
|
||||
|
||||
# Resume the most recent task from the current directory
|
||||
cline --continue
|
||||
|
||||
# Resume in plan mode to review before continuing
|
||||
cline -T abc123def -p "What's left to do?"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "2.5.1",
|
||||
"version": "2.18.0",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "dist/lib.mjs",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface AcpModeOptions {
|
||||
config?: string
|
||||
/** Working directory (default: process.cwd()) */
|
||||
cwd?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
/** Enable verbose/debug logging to stderr */
|
||||
verbose?: boolean
|
||||
}
|
||||
@@ -96,6 +98,7 @@ export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
|
||||
new AgentSideConnection((conn) => {
|
||||
agent = new AcpAgent(conn, {
|
||||
debug: Boolean(options.verbose),
|
||||
hooksDir: options.hooksDir,
|
||||
})
|
||||
return agent
|
||||
}, stream)
|
||||
|
||||
@@ -42,6 +42,7 @@ import { getProviderModelIdKey } from "@shared/storage/provider-keys"
|
||||
import { ClineEndpoint } from "@/config.js"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
|
||||
import { setRuntimeHooksDir } from "@/core/storage/disk"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler.js"
|
||||
import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController.js"
|
||||
@@ -140,6 +141,7 @@ export class ClineAgent implements acp.Agent {
|
||||
|
||||
constructor(options: ClineAgentOptions) {
|
||||
this.options = options
|
||||
setRuntimeHooksDir(options.hooksDir)
|
||||
this.ctx = initializeCliContext({ clineDir: options.clineDir })
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface ClineAgentOptions {
|
||||
debug?: boolean
|
||||
/** Cline Config Directory (defaults to ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +81,8 @@ export interface ClineAgentOptions {
|
||||
export interface AcpAgentOptions {
|
||||
/** Whether debug logging is enabled */
|
||||
debug?: boolean
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
|
||||
interface ApiKeyInputProps {
|
||||
providerName: string
|
||||
@@ -39,7 +39,7 @@ export const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { App } from "./App"
|
||||
|
||||
const CLEAR_SEQUENCE = "\x1b[2J\x1b[3J\x1b[H"
|
||||
|
||||
function setTerminalSize(columns: number, rows: number) {
|
||||
Object.defineProperty(process.stdout, "columns", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: columns,
|
||||
})
|
||||
|
||||
Object.defineProperty(process.stdout, "rows", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: rows,
|
||||
})
|
||||
}
|
||||
|
||||
function hasClearSequenceCall(calls: unknown[][]): boolean {
|
||||
return calls.some((call) => call[0] === CLEAR_SEQUENCE)
|
||||
}
|
||||
|
||||
vi.mock("./ChatView", () => ({
|
||||
ChatView: ({ controller, initialPrompt, initialImages }: any) => {
|
||||
React.useEffect(() => {
|
||||
if (initialPrompt || (initialImages && initialImages.length > 0)) {
|
||||
controller?.initTask(initialPrompt || "", initialImages)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return React.createElement(Text, null, "ChatView")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("./TaskJsonView", () => ({
|
||||
TaskJsonView: () => React.createElement(Text, null, "TaskJsonView"),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryView", () => ({
|
||||
HistoryView: () => React.createElement(Text, null, "HistoryView"),
|
||||
}))
|
||||
|
||||
vi.mock("./ConfigView", () => ({
|
||||
ConfigView: () => React.createElement(Text, null, "ConfigView"),
|
||||
}))
|
||||
|
||||
vi.mock("./AuthView", () => ({
|
||||
AuthView: () => React.createElement(Text, null, "AuthView"),
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
TaskContextProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
StdinProvider: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
describe("App startup prompt resize behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
delete (process.stdout as any).columns
|
||||
delete (process.stdout as any).rows
|
||||
})
|
||||
|
||||
it("does not replay initialPrompt after a width resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(121, 40)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(true)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it("does not remount on height-only resize", async () => {
|
||||
const initTask = vi.fn()
|
||||
setTerminalSize(120, 40)
|
||||
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => {
|
||||
const callback = args.find((arg) => typeof arg === "function")
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
return true
|
||||
}) as any)
|
||||
|
||||
const { unmount } = render(
|
||||
<App controller={{ initTask }} initialPrompt="hello" isRawModeSupported={true} view="welcome" />,
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
writeSpy.mockClear()
|
||||
|
||||
setTerminalSize(120, 45)
|
||||
process.stdout.emit("resize")
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(initTask).toHaveBeenCalledTimes(1)
|
||||
expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -3,14 +3,15 @@
|
||||
* Routes between different views (task, history, config)
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import React, { ReactNode, useCallback, useState } from "react"
|
||||
import { Box, useApp } from "ink"
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from "react"
|
||||
import { StdinProvider } from "../context/StdinContext"
|
||||
import { TaskContextProvider } from "../context/TaskContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { AuthView } from "./AuthView"
|
||||
import { ChatView } from "./ChatView"
|
||||
import { ConfigView } from "./ConfigView"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { HistoryView } from "./HistoryView"
|
||||
import { TaskJsonView } from "./TaskJsonView"
|
||||
|
||||
@@ -90,7 +91,17 @@ interface AppProps {
|
||||
isRawModeSupported?: boolean
|
||||
}
|
||||
|
||||
export const App: React.FC<AppProps> = ({
|
||||
export const App: React.FC<AppProps> = (props) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
return (
|
||||
<ErrorBoundary exit={exit}>
|
||||
<InternalApp {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const InternalApp: React.FC<AppProps> = ({
|
||||
view: initialView,
|
||||
taskId,
|
||||
verbose = false,
|
||||
@@ -135,6 +146,17 @@ export const App: React.FC<AppProps> = ({
|
||||
const { resizeKey } = useTerminalSize()
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | undefined>(taskId)
|
||||
const [pendingInitialPrompt, setPendingInitialPrompt] = useState<string | undefined>(initialPrompt)
|
||||
const [pendingInitialImages, setPendingInitialImages] = useState<string[] | undefined>(initialImages)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInitialPrompt && (!pendingInitialImages || pendingInitialImages.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
setPendingInitialPrompt(undefined)
|
||||
setPendingInitialImages(undefined)
|
||||
}, [pendingInitialPrompt, pendingInitialImages])
|
||||
|
||||
const handleSelectTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId)
|
||||
@@ -242,8 +264,8 @@ export const App: React.FC<AppProps> = ({
|
||||
) : (
|
||||
<ChatView
|
||||
controller={controller}
|
||||
initialImages={initialImages}
|
||||
initialPrompt={initialPrompt}
|
||||
initialImages={pendingInitialImages}
|
||||
initialPrompt={pendingInitialPrompt}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
onExit={onWelcomeExit}
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTaskController } from "../context/TaskContext"
|
||||
import { useLastCompletedAskMessage } from "../hooks/useStateSubscriber"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe } from "../utils/parser"
|
||||
|
||||
interface AskPromptProps {
|
||||
@@ -136,7 +136,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
} else if (promptType === "options") {
|
||||
// Number selection for options, or free text input
|
||||
const parts = jsonParseSafe(text, { options: [] as string[] })
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit free text on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -145,7 +145,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
} else if (input && !key.ctrl && !key.meta) {
|
||||
// Check if it's a number for option selection (only when no text typed yet)
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (textInput === "" && !Number.isNaN(num) && num >= 1 && num <= parts.options.length) {
|
||||
const selectedOption = parts.options[num - 1]
|
||||
sendResponse("messageResponse", selectedOption)
|
||||
@@ -156,7 +156,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "text") {
|
||||
// Text input mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -169,7 +169,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "plan_mode_text") {
|
||||
// Plan mode text input - allows text response or toggle to Act mode
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
// Submit on Enter
|
||||
if (textInput.trim()) {
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -185,7 +185,7 @@ export const AskPrompt: React.FC<AskPromptProps> = ({ onRespond }) => {
|
||||
}
|
||||
} else if (promptType === "completion") {
|
||||
// Task completed - allow follow-up question or exit
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (textInput.trim()) {
|
||||
// Send follow-up question
|
||||
sendResponse("messageResponse", textInput.trim())
|
||||
@@ -401,43 +401,42 @@ function getCliMessagePrefixIcon(message: ClineMessage): string {
|
||||
default:
|
||||
return "❔"
|
||||
}
|
||||
} else {
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
switch (message.say) {
|
||||
case "task":
|
||||
return "📋"
|
||||
case "error":
|
||||
return "❌"
|
||||
case "text":
|
||||
return "💬"
|
||||
case "reasoning":
|
||||
return "🧠"
|
||||
case "completion_result":
|
||||
return "✅"
|
||||
case "user_feedback":
|
||||
return "👤"
|
||||
case "command":
|
||||
case "command_output":
|
||||
return "⚙️"
|
||||
case "tool":
|
||||
return "🔧"
|
||||
case "browser_action":
|
||||
case "browser_action_launch":
|
||||
case "browser_action_result":
|
||||
return "🌐"
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
return "🔌"
|
||||
case "api_req_started":
|
||||
case "api_req_finished":
|
||||
return "🔄"
|
||||
case "checkpoint_created":
|
||||
return "💾"
|
||||
case "info":
|
||||
return "ℹ️"
|
||||
case "generate_explanation":
|
||||
return "📝"
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
|
||||
import { useOcaAuth } from "../hooks/useOcaAuth"
|
||||
import { useScrollableList } from "../hooks/useScrollableList"
|
||||
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
|
||||
import { useValidProviders } from "../utils/providers"
|
||||
import { ApiKeyInput } from "./ApiKeyInput"
|
||||
@@ -79,12 +79,12 @@ const Select: React.FC<{
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(_, key) => {
|
||||
(input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
}
|
||||
},
|
||||
@@ -130,7 +130,7 @@ const TextInput: React.FC<{
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onSubmit(value)
|
||||
} else if (key.backspace || key.delete) {
|
||||
onChange(value.slice(0, -1))
|
||||
@@ -853,7 +853,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setMenuIndex((prev) => (prev > 0 ? prev - 1 : mainMenuItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setMenuIndex((prev) => (prev < mainMenuItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleMainMenuSelect(mainMenuItems[menuIndex].value)
|
||||
}
|
||||
} else if (step === "provider") {
|
||||
@@ -861,7 +861,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setProviderIndex((prev) => (prev > 0 ? prev - 1 : providerItems.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setProviderIndex((prev) => (prev < providerItems.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (providerItems[providerIndex]) {
|
||||
handleProviderSelect(providerItems[providerIndex].value)
|
||||
}
|
||||
@@ -877,7 +877,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
|
||||
setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex))
|
||||
} else if (key.downArrow) {
|
||||
setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (isBrowseAllSelected(clineModelIndex, featuredModels)) {
|
||||
setStep("modelid")
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { getModelList } from "./ModelPicker"
|
||||
import { SearchableList } from "./SearchableList"
|
||||
|
||||
@@ -43,7 +44,7 @@ export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({
|
||||
if (step === "arn_input") {
|
||||
if (key.escape) {
|
||||
onCancel()
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
handleArnSubmit()
|
||||
} else if (key.backspace || key.delete) {
|
||||
setCustomArn((prev) => prev.slice(0, -1))
|
||||
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms: number = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// Type for our exit mock function
|
||||
type ExitMockFn = ReturnType<typeof vi.fn> & (() => void)
|
||||
@@ -126,12 +126,16 @@ vi.mock("../utils/file-search", () => ({
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/slash-commands", () => ({
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}))
|
||||
vi.mock("../utils/slash-commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/slash-commands")>()
|
||||
return {
|
||||
...actual,
|
||||
extractSlashQuery: vi.fn(() => ({ inSlashMode: false, query: "", slashIndex: -1 })),
|
||||
filterCommands: vi.fn(() => []),
|
||||
insertSlashCommand: vi.fn((text: string) => text),
|
||||
sortCommandsWorkflowsFirst: vi.fn((cmds: unknown[]) => cmds),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
|
||||
+100
-71
@@ -108,7 +108,6 @@ import type { ClineAsk, ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey } from "@shared/storage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import { execSync } from "child_process"
|
||||
@@ -137,7 +136,14 @@ import {
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { jsonParseSafe, parseImagesFromInput } from "../utils/parser"
|
||||
import { extractSlashQuery, filterCommands, insertSlashCommand, sortCommandsWorkflowsFirst } from "../utils/slash-commands"
|
||||
import {
|
||||
createCliOnlySlashCommands,
|
||||
extractSlashQuery,
|
||||
filterCommands,
|
||||
getStandaloneSlashCommandToExecute,
|
||||
insertSlashCommand,
|
||||
sortCommandsWorkflowsFirst,
|
||||
} from "../utils/slash-commands"
|
||||
import { waitFor } from "../utils/timeout"
|
||||
import { isFileEditTool, parseToolFromMessage } from "../utils/tools"
|
||||
import { shutdownEvent } from "../vscode-shim"
|
||||
@@ -403,7 +409,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
const PASTE_UPDATE_DEBOUNCE_MS = 50 // Debounce visual updates to avoid flicker
|
||||
|
||||
// Slash command state
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>([])
|
||||
const [availableCommands, setAvailableCommands] = useState<SlashCommandInfo[]>(() => createCliOnlySlashCommands())
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0)
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false)
|
||||
const lastSlashIndexRef = useRef<number>(-1)
|
||||
@@ -614,16 +620,15 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
try {
|
||||
const response = await getAvailableSlashCommands(ctrl, EmptyRequest.create())
|
||||
const cliCommands = response.commands.filter((cmd) => cmd.cliCompatible !== false)
|
||||
// Add CLI-only commands (like /settings) that are handled locally
|
||||
const cliOnlyCommands: SlashCommandInfo[] = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
// Add CLI-only commands (like /settings) that are handled locally.
|
||||
// Seed these synchronously on first render so locally handled commands like
|
||||
// /q and /exit are immediately available, even before the async command
|
||||
// fetch completes. This avoids a race that can make the quit command tests
|
||||
// flaky on slower Windows CI runners.
|
||||
const cliOnlyCommands = createCliOnlySlashCommands()
|
||||
setAvailableCommands([...cliOnlyCommands, ...sortCommandsWorkflowsFirst(cliCommands)])
|
||||
} catch {
|
||||
// Fallback: commands will be empty, menu won't show
|
||||
// Keep CLI-only commands available even if backend command loading fails.
|
||||
}
|
||||
}
|
||||
loadCommands()
|
||||
@@ -843,6 +848,77 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
}, 150)
|
||||
}, [inkExit, onExit])
|
||||
|
||||
const handleCliOnlySlashCommand = useCallback(
|
||||
(commandName: string): boolean => {
|
||||
if (commandName === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "clear") {
|
||||
void clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return true
|
||||
}
|
||||
|
||||
if (commandName === "exit" || commandName === "q") {
|
||||
handleExit()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[clearViewAndResetTask, handleExit, mode, setCursorPos, setTextInput],
|
||||
)
|
||||
|
||||
// Get button config based on the last message state
|
||||
const buttonConfig = useMemo(() => {
|
||||
const lastMsg = messages[messages.length - 1] as ClineMessage | undefined
|
||||
@@ -1102,6 +1178,17 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
|
||||
const inSlashMenu = slashInfo.inSlashMode && filteredCommands.length > 0 && !slashMenuDismissed
|
||||
const inFileMenu = mentionInfo.inMentionMode && fileResults.length > 0 && !inSlashMenu
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode: slashInfo.inSlashMode,
|
||||
hasSlashMenu: inSlashMenu,
|
||||
hasPendingAsk: !!pendingAsk,
|
||||
isSpinnerActive,
|
||||
})
|
||||
|
||||
if (key.return && standaloneSlashCommand && handleCliOnlySlashCommand(standaloneSlashCommand)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Slash command menu navigation (takes priority over file menu)
|
||||
if (inSlashMenu) {
|
||||
@@ -1116,64 +1203,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (key.tab || key.return) {
|
||||
const cmd = filteredCommands[selectedSlashIndex]
|
||||
if (cmd) {
|
||||
// Handle CLI-only commands locally
|
||||
if (cmd.name === "help") {
|
||||
setActivePanel({ type: "help" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "settings") {
|
||||
setActivePanel({ type: "settings" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "models") {
|
||||
const apiConfig = StateManager.get().getApiConfiguration()
|
||||
// Use current mode's provider to determine picker type
|
||||
const provider =
|
||||
mode === "act"
|
||||
? apiConfig.actModeApiProvider || apiConfig.planModeApiProvider
|
||||
: apiConfig.planModeApiProvider || apiConfig.actModeApiProvider
|
||||
const initialMode = !provider ? undefined : provider === "cline" ? "featured-models" : "model-picker"
|
||||
// Set model for current mode (plan or act)
|
||||
const initialModelKey = mode === "act" ? "actModelId" : "planModelId"
|
||||
setActivePanel({ type: "settings", initialMode, initialModelKey })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "history") {
|
||||
setActivePanel({ type: "history" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "clear") {
|
||||
clearViewAndResetTask()
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "exit" || cmd.name === "q") {
|
||||
handleExit()
|
||||
if (handleCliOnlySlashCommand(cmd.name)) {
|
||||
return
|
||||
}
|
||||
const newText = insertSlashCommand(textInput, slashInfo.slashIndex, cmd.name)
|
||||
@@ -1462,13 +1492,12 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
if (item.type === "header") {
|
||||
// Show static robot frame in header (first frame, looking straight ahead)
|
||||
return (
|
||||
<Box flexDirection="column" key="header">
|
||||
<Box flexDirection="column" key="header" marginBottom={1}>
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
{centerText("What can I do for you?")}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export type RestoreType = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
@@ -101,7 +102,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedCheckpoint((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedCheckpoint((i) => Math.min(checkpoints.length - 1, i + 1))
|
||||
} else if (key.return && checkpoints.length > 0) {
|
||||
} else if (isEnterKey(input, key) && checkpoints.length > 0) {
|
||||
setStage("restoreType")
|
||||
}
|
||||
} else if (stage === "restoreType") {
|
||||
@@ -109,7 +110,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
setSelectedRestoreType((i) => Math.max(0, i - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedRestoreType((i) => Math.min(RESTORE_TYPE_OPTIONS.length - 1, i + 1))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
const checkpoint = checkpoints[selectedCheckpoint]
|
||||
const restoreType = RESTORE_TYPE_OPTIONS[selectedRestoreType]
|
||||
if (checkpoint && restoreType) {
|
||||
@@ -120,7 +121,7 @@ export const CheckpointMenu: React.FC<CheckpointMenuProps> = ({ messages, onSele
|
||||
|
||||
// Quick number selection for checkpoints
|
||||
if (stage === "checkpoint") {
|
||||
const num = parseInt(input, 10)
|
||||
const num = Number.parseInt(input, 10)
|
||||
if (!Number.isNaN(num) && num >= 1 && num <= checkpoints.length) {
|
||||
setSelectedCheckpoint(num - 1)
|
||||
setStage("restoreType")
|
||||
|
||||
@@ -84,6 +84,16 @@ describe("ConfigView", () => {
|
||||
)
|
||||
expect(lastFrame()).toContain("Global Settings")
|
||||
})
|
||||
|
||||
it("hides Hooks tab when hooks are disabled", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} hooksEnabled={false} skillsEnabled={true} />)
|
||||
expect(lastFrame()).not.toContain("Hooks")
|
||||
})
|
||||
|
||||
it("shows Hooks tab when hooks are enabled", () => {
|
||||
const { lastFrame } = render(<ConfigView {...defaultProps} hooksEnabled={true} skillsEnabled={true} />)
|
||||
expect(lastFrame()).toContain("Hooks")
|
||||
})
|
||||
})
|
||||
|
||||
describe("value formatting", () => {
|
||||
|
||||
@@ -56,7 +56,13 @@ export interface ObjectEditorState {
|
||||
editValue: string
|
||||
}
|
||||
|
||||
export const EXCLUDED_KEYS = new Set(["taskHistory", "primaryRootIndex", "welcomeViewCompleted", "isNewUser"])
|
||||
export const EXCLUDED_KEYS = new Set([
|
||||
"taskHistory",
|
||||
"primaryRootIndex",
|
||||
"welcomeViewCompleted",
|
||||
"isNewUser",
|
||||
"cliKanbanMigrationAnnouncementShown",
|
||||
])
|
||||
|
||||
export const EDITABLE_TYPES: Set<ValueType> = new Set(["string", "number", "boolean", "object"])
|
||||
export const MAX_VISIBLE = 12
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Text } from "ink"
|
||||
import React from "react"
|
||||
import { ErrorService } from "@/services/error"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
|
||||
type Props = React.PropsWithChildren<{ exit: (error?: Error) => void }>
|
||||
|
||||
async function onReactError(props: Props, error: Error, errorInfo: React.ErrorInfo) {
|
||||
try {
|
||||
await ErrorService.get().captureException(error, { context: "ErrorBoundary", errorInfo })
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
props.exit(error)
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
|
||||
override state = { hasError: false }
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
onReactError(this.props, error, errorInfo)
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box flexDirection="column" height="100%" key="header" width="100%">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Something went wrong. We're sorry.
|
||||
</Text>
|
||||
<Text color="white">Please check the logs for more details.</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Rotating feature tips shown during thinking/acting phases.
|
||||
* Appears after a brief delay and cycles through tips to educate users
|
||||
* about Cline features while they wait.
|
||||
*/
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
interface FeatureTipItem {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FEATURE_TIPS: FeatureTipItem[] = [
|
||||
{
|
||||
text: 'Enable "Double-Check Completion" in settings to have Cline verify its work before finishing a task.',
|
||||
},
|
||||
{
|
||||
text: "Add a .clinerules file to your project root to give Cline project-specific instructions.",
|
||||
},
|
||||
{
|
||||
text: "Press Tab to switch between Plan and Act mode — plan an approach before Cline takes action.",
|
||||
},
|
||||
{
|
||||
text: "Use @ in the chat input to add files, folders, or URLs as context for your task.",
|
||||
},
|
||||
{
|
||||
text: "Set up MCP Servers to give Cline access to external tools and APIs.",
|
||||
},
|
||||
{
|
||||
text: "Cline creates checkpoints after changes — you can always restore to a previous state.",
|
||||
},
|
||||
{
|
||||
text: "Use /compact to condense long conversations and free up context window space.",
|
||||
},
|
||||
{
|
||||
text: "Enable auto-approve for read-only tools like file reads to speed up exploration.",
|
||||
},
|
||||
{
|
||||
text: "Use /settings to configure your API provider and model without leaving the terminal.",
|
||||
},
|
||||
{
|
||||
text: "You can pass images with --images flag or paste image file paths in the chat.",
|
||||
},
|
||||
{
|
||||
text: "Cline can browse websites — ask it to test your local dev server in the browser.",
|
||||
},
|
||||
{
|
||||
text: "Use /reportbug to quickly file a GitHub issue with diagnostic context included.",
|
||||
},
|
||||
{
|
||||
text: "Try 'npm i -g cline' to manage tasks on a Kankan board — orchestrate coding agents across worktrees.",
|
||||
},
|
||||
{
|
||||
text: "Use Shift+Tab to toggle auto-approve all — let Cline work uninterrupted on trusted tasks.",
|
||||
},
|
||||
{
|
||||
text: "Press Up/Down arrows in an empty input to browse your previous task prompts.",
|
||||
},
|
||||
{
|
||||
text: "Type / to see all available commands — /history, /compact, /settings, and more.",
|
||||
},
|
||||
{
|
||||
text: "Use /skills to browse and attach reusable skill files that guide Cline's behavior.",
|
||||
},
|
||||
{
|
||||
text: 'You can disable these tips in /settings → Features → "Feature tips".',
|
||||
},
|
||||
]
|
||||
|
||||
const SHOW_DELAY_MS = 2000
|
||||
const CYCLE_INTERVAL_MS = 8000
|
||||
|
||||
/**
|
||||
* Shows rotating feature tips below the thinking indicator.
|
||||
* Appears after a brief delay and cycles through tips while Cline is thinking/acting.
|
||||
*/
|
||||
export const FeatureTip: React.FC = React.memo(() => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [tipIndex, setTipIndex] = useState(Math.floor(Math.random() * FEATURE_TIPS.length))
|
||||
const cycleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const currentTip = FEATURE_TIPS[tipIndex]
|
||||
|
||||
const advanceTip = useCallback(() => {
|
||||
setTipIndex((prev) => (prev + 1) % FEATURE_TIPS.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
showTimerRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
cycleTimerRef.current = setInterval(advanceTip, CYCLE_INTERVAL_MS)
|
||||
}, SHOW_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
if (showTimerRef.current) {
|
||||
clearTimeout(showTimerRef.current)
|
||||
}
|
||||
if (cycleTimerRef.current) {
|
||||
clearInterval(cycleTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [advanceTip])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<Text color="gray">
|
||||
💡 <Text bold>Tip:</Text> {currentTip.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
@@ -142,7 +142,7 @@ export const HistoryPanelContent: React.FC<HistoryPanelContentProps> = ({ onClos
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return && items[selectedIndex]) {
|
||||
if (isEnterKey(input, key) && items[selectedIndex]) {
|
||||
handleSelect(items[selectedIndex])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { showTaskWithId } from "@/core/controller/task/showTaskWithId"
|
||||
import { StringRequest } from "@/shared/proto/cline/common"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { useTerminalSize } from "../hooks/useTerminalSize"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
interface TaskHistoryItem {
|
||||
id: string
|
||||
@@ -40,7 +41,7 @@ interface HistoryViewProps {
|
||||
/**
|
||||
* Format separator
|
||||
*/
|
||||
function formatSeparator(char: string = "─", width: number = 80): string {
|
||||
function formatSeparator(char = "─", width = 80): string {
|
||||
return char.repeat(Math.max(width, 10))
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export const HistoryView: React.FC<HistoryViewProps> = ({
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1))
|
||||
} else if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((prev) => Math.min(pageItems.length - 1, prev + 1))
|
||||
} else if (key.return && pageItems[selectedIndex]) {
|
||||
} else if (isEnterKey(input, key) && pageItems[selectedIndex]) {
|
||||
onSelect(pageItems[selectedIndex])
|
||||
} else if (key.leftArrow && hasPrevPage) {
|
||||
handlePageChange(currentPage - 1)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
importFromCodex,
|
||||
importFromOpenCode,
|
||||
} from "../utils/import-configs"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { applyProviderConfig } from "../utils/provider-config"
|
||||
|
||||
type ImportStep = "select" | "confirm" | "saving" | "error"
|
||||
@@ -95,13 +96,13 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : keys.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < keys.length - 1 ? prev + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
setStep("confirm")
|
||||
}
|
||||
} else if (step === "confirm") {
|
||||
if (key.upArrow || key.downArrow) {
|
||||
setConfirmIndex((prev) => (prev === 0 ? 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(input, key)) {
|
||||
if (confirmIndex === 0) {
|
||||
handleConfirm()
|
||||
} else {
|
||||
@@ -109,7 +110,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ source, onComplete, onCa
|
||||
}
|
||||
}
|
||||
} else if (step === "error") {
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render } from "ink-testing-library"
|
||||
import { createElement } from "react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { KanbanMigrationView } from "./KanbanMigrationView"
|
||||
|
||||
describe("KanbanMigrationView", () => {
|
||||
it("renders the migration options", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { lastFrame } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
expect(lastFrame()).toContain("Introducing Cline Kanban!")
|
||||
expect(lastFrame()).toContain("Open the new experience")
|
||||
expect(lastFrame()).toContain("Launch Cline Kanban and start there by default.")
|
||||
expect(lastFrame()).toContain("cline --tui")
|
||||
expect(lastFrame()).toContain("You can always run cline --tui for the terminal experience.")
|
||||
expect(lastFrame()).toContain("Exit")
|
||||
})
|
||||
|
||||
it("selects the highlighted option with Enter", () => {
|
||||
const onSelect = vi.fn()
|
||||
const { stdin } = render(createElement(KanbanMigrationView, { isRawModeSupported: true, onSelect }))
|
||||
|
||||
stdin.write("\r")
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("kanban")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Box, Text, useApp, useInput } from "ink"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { StdinProvider, useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
import { type KanbanMigrationAction } from "../utils/kanban"
|
||||
import { StaticRobotFrame } from "./AsciiMotionCli"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
|
||||
interface KanbanMigrationViewProps {
|
||||
isRawModeSupported: boolean
|
||||
onSelect: (action: KanbanMigrationAction) => void
|
||||
}
|
||||
|
||||
interface MigrationMenuItem {
|
||||
label: string
|
||||
description: string
|
||||
value: KanbanMigrationAction
|
||||
}
|
||||
|
||||
const InternalKanbanMigrationView: React.FC<Pick<KanbanMigrationViewProps, "onSelect">> = ({ onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const items = useMemo<MigrationMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Open the new experience",
|
||||
description: "Launch Cline Kanban and start there by default.",
|
||||
value: "kanban",
|
||||
},
|
||||
{
|
||||
label: "Exit",
|
||||
description: "You can always run cline --tui for the terminal experience.",
|
||||
value: "exit",
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onSelect("exit")
|
||||
exit()
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
} else if (isEnterKey(input, key)) {
|
||||
onSelect(items[selectedIndex].value)
|
||||
exit()
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<StaticRobotFrame />
|
||||
<Text> </Text>
|
||||
<Text bold color="white">
|
||||
Introducing Cline Kanban!
|
||||
</Text>
|
||||
<Text color="gray">A board for orchestrating coding agents across worktrees, right from your browser.</Text>
|
||||
<Text> </Text>
|
||||
{items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
return (
|
||||
<Box flexDirection="column" key={item.value} marginBottom={1}>
|
||||
<Text color={isSelected ? COLORS.primaryBlue : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text color="gray"> {item.description}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<Text> </Text>
|
||||
<Text color="gray">Use arrow keys to navigate, Enter to select, Esc or Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const KanbanMigrationView: React.FC<KanbanMigrationViewProps> = ({ isRawModeSupported, onSelect }) => {
|
||||
const { exit } = useApp()
|
||||
|
||||
return (
|
||||
<ErrorBoundary exit={exit}>
|
||||
<StdinProvider isRawModeSupported={isRawModeSupported}>
|
||||
<InternalKanbanMigrationView onSelect={onSelect} />
|
||||
</StdinProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
sapAiCoreModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
wandbDefaultModelId,
|
||||
wandbModels,
|
||||
xaiDefaultModelId,
|
||||
xaiModels,
|
||||
} from "@/shared/api"
|
||||
@@ -101,6 +103,7 @@ export const providerModels: Record<string, { models: Record<string, unknown>; d
|
||||
sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId },
|
||||
sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId },
|
||||
vertex: { models: vertexModels, defaultId: vertexDefaultModelId },
|
||||
wandb: { models: wandbModels, defaultId: wandbDefaultModelId },
|
||||
xai: { models: xaiModels, defaultId: xaiDefaultModelId },
|
||||
zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId },
|
||||
}
|
||||
|
||||
@@ -1,112 +1,38 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandName, getStandaloneSlashCommandToExecute } from "../utils/slash-commands"
|
||||
|
||||
// Mock ink's useApp
|
||||
const mockExit = vi.fn()
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>()
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({ exit: mockExit }),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock child_process
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
const cliOnlySlashCommands = CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskContext: () => ({
|
||||
controller: {},
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("Quit Command (/q and /exit)", () => {
|
||||
const mockOnExit = vi.fn()
|
||||
it("prioritizes /q as the selected slash command for an exact q query", () => {
|
||||
const result = filterCommands(cliOnlySlashCommands, "q")
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
expect(result[0]?.name).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /q is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
|
||||
// Type /q
|
||||
stdin.write("/q")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("detects /q as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/q")).toBe("q")
|
||||
})
|
||||
|
||||
it("should exit the application when /exit is selected from slash menu", async () => {
|
||||
const { stdin } = render(<ChatView onExit={mockOnExit} />)
|
||||
await delay()
|
||||
it("detects /exit as a standalone slash command", () => {
|
||||
expect(getStandaloneSlashCommandName("/exit")).toBe("exit")
|
||||
})
|
||||
|
||||
// Type /exit
|
||||
stdin.write("/exit")
|
||||
await delay()
|
||||
|
||||
// Press Enter
|
||||
stdin.write("\r")
|
||||
|
||||
// handleExit has a 150ms timeout
|
||||
await delay(200)
|
||||
|
||||
expect(mockExit).toHaveBeenCalled()
|
||||
expect(mockOnExit).toHaveBeenCalled()
|
||||
it("resolves /q to direct execution when no slash menu is active", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("q")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Box, Text, useInput } from "ink"
|
||||
import React, { useState } from "react"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isEnterKey } from "../utils/input"
|
||||
|
||||
export interface SelectListItem {
|
||||
id: string
|
||||
@@ -31,7 +32,7 @@ export function SelectList<T extends SelectListItem>({ items, onSelect, isActive
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1))
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0))
|
||||
} else if (key.return) {
|
||||
} else if (isEnterKey(_input, key)) {
|
||||
const item = items[selectedIndex]
|
||||
if (item) {
|
||||
onSelect(item)
|
||||
|
||||
@@ -129,6 +129,12 @@ const FEATURE_SETTINGS = {
|
||||
label: "Double-check completion",
|
||||
description: "Reject first completion attempt and require re-verification",
|
||||
},
|
||||
showFeatureTips: {
|
||||
stateKey: "showFeatureTips",
|
||||
default: true,
|
||||
label: "Feature tips",
|
||||
description: "Show tips during thinking phases",
|
||||
},
|
||||
} as const
|
||||
|
||||
type FeatureKey = keyof typeof FEATURE_SETTINGS
|
||||
|
||||
@@ -38,6 +38,46 @@ import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
// Helper to wait for async state updates
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type WaitForConditionOptions = {
|
||||
timeoutMs?: number
|
||||
intervalMs?: number
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
const waitForCondition = async (
|
||||
condition: () => boolean,
|
||||
{ timeoutMs = 1000, intervalMs = 25, errorMessage }: WaitForConditionOptions,
|
||||
) => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (condition()) {
|
||||
return
|
||||
}
|
||||
await delay(intervalMs)
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const waitForFrameToInclude = async (lastFrame: () => string | undefined, text: string) =>
|
||||
waitForCondition(() => (lastFrame() || "").includes(text), {
|
||||
errorMessage: `Expected frame to include: ${text}`,
|
||||
})
|
||||
|
||||
const waitForFrameToExclude = async (lastFrame: () => string | undefined, text: string) =>
|
||||
waitForCondition(() => !(lastFrame() || "").includes(text), {
|
||||
errorMessage: `Expected frame to exclude: ${text}`,
|
||||
})
|
||||
|
||||
const waitForMockToBeCalled = async (mockFn: { mock: { calls: unknown[] } }) =>
|
||||
waitForCondition(() => mockFn.mock.calls.length > 0, {
|
||||
errorMessage: "Expected mock to be called",
|
||||
})
|
||||
|
||||
const waitForSkillsPanelReady = async (lastFrame: () => string | undefined, expectedText: string) => {
|
||||
await waitForFrameToExclude(lastFrame, "Loading skills...")
|
||||
await waitForFrameToInclude(lastFrame, expectedText)
|
||||
}
|
||||
|
||||
describe("SkillsPanelContent", () => {
|
||||
const mockController = {} as any
|
||||
const mockOnClose = vi.fn()
|
||||
@@ -64,11 +104,11 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "No skills installed.")
|
||||
|
||||
stdin.write("\x1B") // Escape
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockOnClose)
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -79,11 +119,11 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "test-skill")
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockOnUseSkill)
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md")
|
||||
})
|
||||
@@ -94,11 +134,11 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "test-skill")
|
||||
|
||||
stdin.write(" ") // Space
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockToggleSkill)
|
||||
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(
|
||||
mockController,
|
||||
@@ -116,15 +156,17 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "skill")
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
// Use vim-style navigation here because it's more deterministic in the
|
||||
// full suite than raw arrow escape sequences on Windows.
|
||||
stdin.write("j")
|
||||
await waitForFrameToInclude(lastFrame, "❯ Browse more skills at https://skills.sh/")
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockExec)
|
||||
|
||||
// Should have called exec with open command
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
@@ -141,16 +183,16 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "skill-1")
|
||||
|
||||
// Navigate down
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
await delay()
|
||||
await waitForFrameToInclude(lastFrame, "❯ ● skill-2")
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockOnUseSkill)
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
@@ -164,16 +206,16 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "skill-1")
|
||||
|
||||
// Navigate down with j
|
||||
stdin.write("j")
|
||||
await delay()
|
||||
await waitForFrameToInclude(lastFrame, "❯ ● skill-2")
|
||||
|
||||
// Press Enter - should use second skill
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockOnUseSkill)
|
||||
|
||||
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
|
||||
})
|
||||
@@ -186,10 +228,11 @@ describe("SkillsPanelContent", () => {
|
||||
mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed"))
|
||||
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
await waitForSkillsPanelReady(lastFrame, "test-skill")
|
||||
|
||||
stdin.write(" ") // Space to toggle
|
||||
await delay(100)
|
||||
await waitForMockToBeCalled(mockToggleSkill)
|
||||
await waitForFrameToInclude(lastFrame, "● test-skill")
|
||||
|
||||
// toggleSkill was called with enabled: false (toggled from true)
|
||||
expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false }))
|
||||
@@ -204,15 +247,15 @@ describe("SkillsPanelContent", () => {
|
||||
localSkills: [],
|
||||
})
|
||||
|
||||
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForSkillsPanelReady(lastFrame, "only-skill")
|
||||
|
||||
// Navigate up from first item (should wrap to last - marketplace)
|
||||
stdin.write("\x1B[A") // Up arrow
|
||||
await delay()
|
||||
await waitForFrameToInclude(lastFrame, "❯ Browse more skills at https://skills.sh/")
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
await delay()
|
||||
await waitForMockToBeCalled(mockExec)
|
||||
|
||||
// Should have opened marketplace (wrapped to last item)
|
||||
expect(mockExec).toHaveBeenCalled()
|
||||
@@ -221,8 +264,9 @@ describe("SkillsPanelContent", () => {
|
||||
|
||||
describe("skill loading", () => {
|
||||
it("should call refreshSkills on mount", async () => {
|
||||
render(<SkillsPanelContent {...defaultProps} />)
|
||||
await delay()
|
||||
const { lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
|
||||
await waitForMockToBeCalled(mockRefreshSkills)
|
||||
await waitForFrameToExclude(lastFrame, "Loading skills...")
|
||||
|
||||
expect(mockRefreshSkills).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
import { exec } from "node:child_process"
|
||||
import os from "node:os"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshSkills } from "@/core/controller/file/refreshSkills"
|
||||
import { toggleSkill } from "@/core/controller/file/toggleSkill"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
|
||||
@@ -38,6 +38,14 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const inputStateRef = useRef({
|
||||
isLoading: true,
|
||||
selectedIndex: 0,
|
||||
skillEntries: [] as Array<{ skill: SkillInfo; isGlobal: boolean }>,
|
||||
})
|
||||
const handleToggleRef = useRef<() => Promise<void>>(async () => {})
|
||||
const handleUseRef = useRef<() => void>(() => {})
|
||||
const openMarketplaceRef = useRef<() => void>(() => {})
|
||||
|
||||
// Load skills on mount
|
||||
useEffect(() => {
|
||||
@@ -58,8 +66,12 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
// Build flat list of skills with source info (global first, then local, alphabetical within each)
|
||||
const skillEntries = useMemo(() => {
|
||||
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
|
||||
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
|
||||
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
|
||||
globalSkills.forEach((skill) => {
|
||||
entries.push({ skill, isGlobal: true })
|
||||
})
|
||||
localSkills.forEach((skill) => {
|
||||
entries.push({ skill, isGlobal: false })
|
||||
})
|
||||
return entries.sort((a, b) => {
|
||||
if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1
|
||||
return a.skill.name.localeCompare(b.skill.name)
|
||||
@@ -117,6 +129,14 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
handleToggleRef.current = handleToggle
|
||||
handleUseRef.current = handleUse
|
||||
openMarketplaceRef.current = openMarketplace
|
||||
inputStateRef.current = {
|
||||
isLoading,
|
||||
selectedIndex,
|
||||
skillEntries,
|
||||
}
|
||||
|
||||
// Total items = skills + 1 for marketplace link
|
||||
const totalItems = skillEntries.length + 1
|
||||
@@ -132,6 +152,14 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
return
|
||||
}
|
||||
|
||||
const { isLoading, selectedIndex, skillEntries } = inputStateRef.current
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const totalItems = skillEntries.length + 1
|
||||
const isMarketplaceSelected = selectedIndex === skillEntries.length
|
||||
|
||||
// Navigation
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
|
||||
@@ -143,16 +171,16 @@ export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controll
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (key.return) {
|
||||
if (isEnterKey(input, key)) {
|
||||
if (isMarketplaceSelected) {
|
||||
openMarketplace()
|
||||
openMarketplaceRef.current()
|
||||
} else {
|
||||
handleUse()
|
||||
handleUseRef.current()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input === " " && !isMarketplaceSelected) {
|
||||
handleToggle()
|
||||
void handleToggleRef.current()
|
||||
return
|
||||
}
|
||||
},
|
||||
@@ -248,7 +276,7 @@ const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill,
|
||||
{skill.description && (
|
||||
<Box marginLeft={4}>
|
||||
<Text color="gray">
|
||||
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
|
||||
{skill.description.length > 60 ? `${skill.description.slice(0, 57)}...` : skill.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { COLORS } from "../constants/colors"
|
||||
import { FeatureTip } from "./FeatureTip"
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
mode?: "act" | "plan"
|
||||
@@ -52,6 +54,7 @@ const ShimmerText: React.FC<{ text: string; color: string; shimmerPos: number }>
|
||||
}
|
||||
|
||||
export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "act", startTime, onCancel }) => {
|
||||
const showFeatureTips = StateManager.get().getGlobalSettingsKey("showFeatureTips") ?? true
|
||||
const message = mode === "plan" ? "Planning" : "Acting"
|
||||
const color = mode === "plan" ? "yellow" : COLORS.primaryBlue
|
||||
|
||||
@@ -118,9 +121,12 @@ export const ThinkingIndicator: React.FC<ThinkingIndicatorProps> = ({ mode = "ac
|
||||
}, [startTime, elapsedMs])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
<Box flexDirection="column">
|
||||
<Box paddingLeft={1}>
|
||||
<ShimmerText color={color} shimmerPos={shimmerPos} text={fullText} />
|
||||
{elapsedStr && <Text color="gray"> ({elapsedStr} · esc to interrupt)</Text>}
|
||||
</Box>
|
||||
{showFeatureTips && <FeatureTip />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
/**
|
||||
* Welcome view component
|
||||
* Shows an interactive prompt when user starts cline without a command
|
||||
* Supports file mentions with @
|
||||
*/
|
||||
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { ApiProvider } from "@/shared/api"
|
||||
import { getProviderDefaultModelId, getProviderModelIdKey, Mode, SettingsKey } from "@/shared/storage"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import {
|
||||
checkAndWarnRipgrepMissing,
|
||||
extractMentionQuery,
|
||||
type FileSearchResult,
|
||||
getRipgrepInstallInstructions,
|
||||
insertMention,
|
||||
searchWorkspaceFiles,
|
||||
} from "../utils/file-search"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { parseImagesFromInput } from "../utils/parser"
|
||||
import { AccountInfoView } from "./AccountInfoView"
|
||||
import { FileMentionMenu } from "./FileMentionMenu"
|
||||
|
||||
interface WelcomeViewProps {
|
||||
onSubmit: (prompt: string, imagePaths: string[]) => void
|
||||
onExit?: () => void
|
||||
controller?: any
|
||||
}
|
||||
|
||||
// ASCII art Cline logo
|
||||
const CLINE_LOGO = [
|
||||
" ::::::: ",
|
||||
" ::::::::: ",
|
||||
" ::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
":::::::: ::::: ::::::::",
|
||||
":::::::: ::::: ::::::::",
|
||||
" ::::::: ::::: ::::::: ",
|
||||
" ::::::: ::::::: ::::::: ",
|
||||
" ::::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::::: ",
|
||||
" ::::::::::::::::::::::: ",
|
||||
" :::::::::::::::: ",
|
||||
]
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
const RIPGREP_WARNING_DURATION_MS = 5000
|
||||
const MAX_SEARCH_RESULTS = 15
|
||||
|
||||
export const WelcomeView: React.FC<WelcomeViewProps> = ({ onSubmit, onExit, controller }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [textInput, setTextInput] = useState("")
|
||||
const [fileResults, setFileResults] = useState<FileSearchResult[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showRipgrepWarning, setShowRipgrepWarning] = useState(false)
|
||||
const [escPressedOnce, setEscPressedOnce] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>(() => {
|
||||
const stateManager = StateManager.get()
|
||||
return stateManager.getGlobalSettingsKey("mode") || "act"
|
||||
})
|
||||
|
||||
const provider = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const mode = stateManager.getGlobalSettingsKey("mode") as string
|
||||
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = stateManager.getGlobalSettingsKey(providerKey) as string
|
||||
return currentProvider || "cline"
|
||||
}, [controller])
|
||||
|
||||
// Get model ID based on current mode and provider
|
||||
// Different providers use different state keys (e.g., cline uses actModeOpenRouterModelId)
|
||||
const modelId = useMemo(() => {
|
||||
const stateManager = StateManager.get()
|
||||
const modelKey = getProviderModelIdKey(provider as ApiProvider, mode)
|
||||
return (
|
||||
(stateManager.getGlobalSettingsKey(modelKey as SettingsKey) as string) ||
|
||||
getProviderDefaultModelId(provider as ApiProvider)
|
||||
)
|
||||
}, [mode, provider])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const newMode: Mode = mode === "act" ? "plan" : "act"
|
||||
setMode(newMode)
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("mode", newMode)
|
||||
}, [mode])
|
||||
|
||||
const refs = useRef({
|
||||
searchTimeout: null as NodeJS.Timeout | null,
|
||||
lastQuery: "",
|
||||
hasCheckedRipgrep: false,
|
||||
})
|
||||
|
||||
const { prompt, imagePaths } = parseImagesFromInput(textInput)
|
||||
|
||||
const mentionInfo = useMemo(() => extractMentionQuery(textInput), [textInput])
|
||||
|
||||
const workspacePath = useMemo(() => {
|
||||
try {
|
||||
const root = controller?.getWorkspaceManagerSync?.()?.getPrimaryRoot?.()
|
||||
if (root?.path) {
|
||||
return root.path
|
||||
}
|
||||
} catch {
|
||||
// Fallback to cwd
|
||||
}
|
||||
return process.cwd()
|
||||
}, [controller])
|
||||
|
||||
// Search for files when in mention mode
|
||||
useEffect(() => {
|
||||
const { current: r } = refs
|
||||
|
||||
if (!mentionInfo.inMentionMode) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
r.searchTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ripgrep on first mention trigger
|
||||
if (!r.hasCheckedRipgrep) {
|
||||
r.hasCheckedRipgrep = true
|
||||
if (checkAndWarnRipgrepMissing()) {
|
||||
setShowRipgrepWarning(true)
|
||||
setTimeout(() => setShowRipgrepWarning(false), RIPGREP_WARNING_DURATION_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const { query } = mentionInfo
|
||||
if (query === r.lastQuery) {
|
||||
return
|
||||
}
|
||||
r.lastQuery = query
|
||||
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
setIsSearching(true)
|
||||
|
||||
r.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchWorkspaceFiles(query, workspacePath, MAX_SEARCH_RESULTS)
|
||||
setFileResults(results)
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setFileResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
if (r.searchTimeout) {
|
||||
clearTimeout(r.searchTimeout)
|
||||
}
|
||||
}
|
||||
}, [mentionInfo.inMentionMode, mentionInfo.query, workspacePath])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Filter out mouse escape sequences
|
||||
if (isMouseEscapeSequence(input)) {
|
||||
return
|
||||
}
|
||||
|
||||
const inMenu = mentionInfo.inMentionMode && fileResults.length > 0
|
||||
|
||||
// Menu navigation
|
||||
if (inMenu) {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : fileResults.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < fileResults.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (key.tab || key.return) {
|
||||
const file = fileResults[selectedIndex]
|
||||
if (file) {
|
||||
setTextInput(insertMention(textInput, mentionInfo.atIndex, file.path))
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape) {
|
||||
setFileResults([])
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
if (key.tab && !mentionInfo.inMentionMode) {
|
||||
toggleMode()
|
||||
return
|
||||
}
|
||||
if (key.return && !mentionInfo.inMentionMode) {
|
||||
if (prompt.trim() || imagePaths.length > 0) {
|
||||
onSubmit(prompt.trim(), imagePaths)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.escape && !mentionInfo.inMentionMode) {
|
||||
if (escPressedOnce) {
|
||||
onExit?.()
|
||||
} else {
|
||||
setEscPressedOnce(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setTextInput((prev) => prev.slice(0, -1))
|
||||
setEscPressedOnce(false)
|
||||
return
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
|
||||
setTextInput((prev) => prev + input)
|
||||
setEscPressedOnce(false)
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
const borderColor = mode === "act" ? "blue" : "yellow"
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Account/Provider info at top */}
|
||||
{controller && (
|
||||
<Box marginBottom={1}>
|
||||
<AccountInfoView controller={controller} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cline logo - centered */}
|
||||
<Box alignItems="center" flexDirection="column">
|
||||
{CLINE_LOGO.map((line, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static array that never changes
|
||||
<Text color="white" key={idx}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Main prompt - centered, bold */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Text bold color="white">
|
||||
What can I do for you?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Ripgrep warning if needed */}
|
||||
{showRipgrepWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">⚠ ripgrep not found - file search will be slower. </Text>
|
||||
<Text color="gray">Install: {getRipgrepInstallInstructions()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Input field with border */}
|
||||
<Box
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%">
|
||||
<Text>{textInput}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
|
||||
{/* Model ID and Mode toggle row */}
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
{/* Model ID on left */}
|
||||
<Text color="gray">{modelId}</Text>
|
||||
|
||||
{/* Mode toggle on right */}
|
||||
<Box gap={1}>
|
||||
<Box>
|
||||
<Text bold={mode === "plan"} color={mode === "plan" ? "yellow" : "gray"}>
|
||||
{mode === "plan" ? "●" : "○"} Plan
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold={mode === "act"} color={mode === "act" ? "blue" : "gray"}>
|
||||
{mode === "act" ? "●" : "○"} Act
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray">(Tab)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* File mention menu - below input */}
|
||||
{mentionInfo.inMentionMode && (
|
||||
<FileMentionMenu
|
||||
isLoading={isSearching}
|
||||
query={mentionInfo.query}
|
||||
results={fileResults}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{imagePaths.length > 0 && (
|
||||
<Text color="magenta">
|
||||
📎 {imagePaths.length} image{imagePaths.length > 1 ? "s" : ""} attached
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
<Box>
|
||||
<Text color="gray">Enter to submit · @ to mention files · </Text>
|
||||
<Text bold={escPressedOnce} color={escPressedOnce ? "white" : "gray"}>
|
||||
{escPressedOnce ? "Press Esc again to exit" : "Esc to exit"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class CliDiffServiceClient implements DiffServiceClientInterface {
|
||||
* CLI implementation of EnvService - handles environment operations
|
||||
*/
|
||||
export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
private clipboardContent: string = ""
|
||||
private clipboardContent = ""
|
||||
|
||||
private getTelemetrySetting(): proto.host.Setting {
|
||||
// Read from StateManager - defaults to ENABLED if not set or "unset"
|
||||
@@ -102,6 +102,8 @@ export class CliEnvServiceClient implements EnvServiceClientInterface {
|
||||
version: CLI_VERSION,
|
||||
platform: "Cline CLI - Node.js",
|
||||
clineType: ClineClient.Cli,
|
||||
// remoteName is intentionally omitted — the CLI runs locally on the user's machine.
|
||||
// If CLI-in-container scenarios arise, populate this field to enable remote cadence tuning.
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ import { useCallback, useEffect, useRef, useState } from "react"
|
||||
* to unmount and remount everything from scratch. This resets Ink's internal tracking
|
||||
* AND re-renders Static content since the components are brand new instances.
|
||||
*
|
||||
* We only run this full recovery when terminal width changes. Height-only resizes do not
|
||||
* affect wrapping in the same way and should not restart the task view.
|
||||
*
|
||||
* Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then
|
||||
* stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1).
|
||||
*
|
||||
@@ -41,6 +44,8 @@ export function useTerminalSize() {
|
||||
})
|
||||
const [resizeKey, setResizeKey] = useState(0)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const previousColumnsRef = useRef(process.stdout.columns || 80)
|
||||
const pendingWidthRefreshRef = useRef(false)
|
||||
|
||||
const refreshAfterResize = useCallback(() => {
|
||||
// Clear terminal + scrollback to wipe stale content from old width
|
||||
@@ -56,17 +61,33 @@ export function useTerminalSize() {
|
||||
|
||||
useEffect(() => {
|
||||
function updateSize() {
|
||||
const nextColumns = process.stdout.columns || 80
|
||||
const nextRows = process.stdout.rows || 24
|
||||
const didWidthChange = nextColumns !== previousColumnsRef.current
|
||||
previousColumnsRef.current = nextColumns
|
||||
|
||||
setSize({
|
||||
columns: process.stdout.columns || 80,
|
||||
rows: process.stdout.rows || 24,
|
||||
columns: nextColumns,
|
||||
rows: nextRows,
|
||||
})
|
||||
|
||||
if (didWidthChange) {
|
||||
pendingWidthRefreshRef.current = true
|
||||
}
|
||||
|
||||
if (!pendingWidthRefreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce: wait 300ms after last resize event to do full recovery
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
refreshAfterResize()
|
||||
if (pendingWidthRefreshRef.current) {
|
||||
refreshAfterResize()
|
||||
pendingWidthRefreshRef.current = false
|
||||
}
|
||||
debounceRef.current = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -76,6 +97,7 @@ export function useTerminalSize() {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
pendingWidthRefreshRef.current = false
|
||||
}
|
||||
}, [refreshAfterResize])
|
||||
|
||||
|
||||
+217
-27
@@ -1,5 +1,6 @@
|
||||
import { Command } from "commander"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { captureUnhandledException } from "."
|
||||
|
||||
/**
|
||||
* Tests for CLI command parsing and structure
|
||||
@@ -10,6 +11,22 @@ import { beforeEach, describe, expect, it } from "vitest"
|
||||
describe("CLI Commands", () => {
|
||||
let program: Command
|
||||
|
||||
function getCommand(name: string): Command {
|
||||
const command = program.commands.find((candidate) => candidate.name() === name)
|
||||
if (!command) {
|
||||
throw new Error(`Missing command: ${name}`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function getSubcommand(commandName: string, subcommandName: string): Command {
|
||||
const subcommand = getCommand(commandName).commands.find((candidate) => candidate.name() === subcommandName)
|
||||
if (!subcommand) {
|
||||
throw new Error(`Missing subcommand: ${commandName} ${subcommandName}`)
|
||||
}
|
||||
return subcommand
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh program instance for each test
|
||||
program = new Command()
|
||||
@@ -25,6 +42,7 @@ describe("CLI Commands", () => {
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("-m, --model <model>", "Model to use")
|
||||
.option("-i, --images <paths...>", "Image file paths")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
@@ -33,6 +51,9 @@ describe("CLI Commands", () => {
|
||||
.option("--thinking [tokens]", "Enable extended thinking")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
@@ -62,6 +83,28 @@ describe("CLI Commands", () => {
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "Command args for stdio, or URL for remote")
|
||||
.option("--type <type>", "Transport type", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option("--config <path>", "Configuration directory")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description("Run kanban")
|
||||
.action(() => {})
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
@@ -72,6 +115,13 @@ describe("CLI Commands", () => {
|
||||
.option("--thinking [tokens]", "Enable extended thinking")
|
||||
.option("--reasoning-effort <effort>", "Reasoning effort")
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Additional hooks directory")
|
||||
.option("--auto-approve-all", "Enable auto-approve all")
|
||||
.option("--update", "Check for updates and install if available")
|
||||
.option("--kanban", "Run kanban")
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -88,91 +138,119 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --act flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--act"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --plan flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--plan"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().plan).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --yolo flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--yolo"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-approve-all flag", () => {
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-approve-all"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --model option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().model).toBe("claude-sonnet-4-20250514")
|
||||
})
|
||||
|
||||
it("should parse --images option with multiple paths", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--images", "/path/to/img1.png", "/path/to/img2.jpg"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().images).toEqual(["/path/to/img1.png", "/path/to/img2.jpg"])
|
||||
})
|
||||
|
||||
it("should parse --verbose flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--verbose"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --cwd option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--cwd", "/some/path"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().cwd).toBe("/some/path")
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--config", "/custom/config"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().config).toBe("/custom/config")
|
||||
})
|
||||
|
||||
it("should parse --thinking flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --thinking with token budget", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--thinking", "8000"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().thinking).toBe("8000")
|
||||
})
|
||||
|
||||
it("should parse --reasoning-effort option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--reasoning-effort", "high"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
it("should parse --max-consecutive-mistakes option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--max-consecutive-mistakes", "999"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
|
||||
})
|
||||
|
||||
it("should parse --hooks-dir option", () => {
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--hooks-dir", "/tmp/hooks"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().hooksDir).toBe("/tmp/hooks")
|
||||
})
|
||||
|
||||
it("should parse --double-check-completion flag", () => {
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--double-check-completion"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().doubleCheckCompletion).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-condense flag", () => {
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "--auto-condense"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().autoCondense).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const taskCmd = getCommand("task")
|
||||
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
taskCmd.parse(args, { from: "user" })
|
||||
expect(taskCmd.opts().act).toBe(true)
|
||||
@@ -183,26 +261,26 @@ describe("CLI Commands", () => {
|
||||
|
||||
describe("history command", () => {
|
||||
it("should have default limit of 10", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("10")
|
||||
})
|
||||
|
||||
it("should have default page of 1", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
historyCmd.parse([], { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("1")
|
||||
})
|
||||
|
||||
it("should parse --limit option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--limit", "20"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("20")
|
||||
})
|
||||
|
||||
it("should parse --page option", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["--page", "3"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().page).toBe("3")
|
||||
@@ -215,7 +293,7 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const historyCmd = getCommand("history")
|
||||
const args = ["-n", "5", "-p", "2"]
|
||||
historyCmd.parse(args, { from: "user" })
|
||||
expect(historyCmd.opts().limit).toBe("5")
|
||||
@@ -230,13 +308,34 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --config option", () => {
|
||||
const configCmd = program.commands.find((c) => c.name() === "config")!
|
||||
const configCmd = getCommand("config")
|
||||
const args = ["--config", "/custom/path"]
|
||||
configCmd.parse(args, { from: "user" })
|
||||
expect(configCmd.opts().config).toBe("/custom/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command", () => {
|
||||
it("should parse kanban command", () => {
|
||||
const args = ["node", "cli", "kanban"]
|
||||
program.parse(args)
|
||||
})
|
||||
})
|
||||
|
||||
describe("update command", () => {
|
||||
it("should parse update command", () => {
|
||||
const args = ["node", "cli", "update"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse --verbose on update command", () => {
|
||||
const updateCmd = getCommand("update")
|
||||
const args = ["--verbose"]
|
||||
updateCmd.parse(args, { from: "user" })
|
||||
expect(updateCmd.opts().verbose).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
@@ -244,35 +343,35 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
|
||||
it("should parse --provider option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--provider", "openai"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("should parse --apikey option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--apikey", "sk-test-key"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().apikey).toBe("sk-test-key")
|
||||
})
|
||||
|
||||
it("should parse --modelid option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--modelid", "gpt-4"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().modelid).toBe("gpt-4")
|
||||
})
|
||||
|
||||
it("should parse --baseurl option", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["--baseurl", "https://api.example.com"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().baseurl).toBe("https://api.example.com")
|
||||
})
|
||||
|
||||
it("should parse short flags", () => {
|
||||
const authCmd = program.commands.find((c) => c.name() === "auth")!
|
||||
const authCmd = getCommand("auth")
|
||||
const args = ["-p", "anthropic", "-k", "key123", "-m", "claude-sonnet-4-20250514"]
|
||||
authCmd.parse(args, { from: "user" })
|
||||
expect(authCmd.opts().provider).toBe("anthropic")
|
||||
@@ -281,6 +380,30 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp command", () => {
|
||||
it("should parse mcp add stdio syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "kanban", "--", "kanban", "mcp"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should parse mcp add remote http syntax", () => {
|
||||
const args = ["node", "cli", "mcp", "add", "linear", "https://mcp.linear.app/mcp", "--type", "http"]
|
||||
program.parse(args)
|
||||
})
|
||||
|
||||
it("should default mcp add type to stdio", () => {
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("stdio")
|
||||
})
|
||||
|
||||
it("should parse mcp add type option", () => {
|
||||
const addCmd = getSubcommand("mcp", "add")
|
||||
addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("http")
|
||||
})
|
||||
})
|
||||
|
||||
describe("default command (interactive mode)", () => {
|
||||
it("should parse optional prompt argument", () => {
|
||||
const args = ["node", "cli", "do something"]
|
||||
@@ -321,6 +444,31 @@ describe("CLI Commands", () => {
|
||||
program.parse(["node", "cli", "--max-consecutive-mistakes", "7"])
|
||||
expect(program.opts().maxConsecutiveMistakes).toBe("7")
|
||||
})
|
||||
|
||||
it("should parse --hooks-dir option", () => {
|
||||
program.parse(["node", "cli", "--hooks-dir", "/tmp/hooks"])
|
||||
expect(program.opts().hooksDir).toBe("/tmp/hooks")
|
||||
})
|
||||
|
||||
it("should parse --auto-approve-all flag", () => {
|
||||
program.parse(["node", "cli", "--auto-approve-all"])
|
||||
expect(program.opts().autoApproveAll).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --kanban flag", () => {
|
||||
program.parse(["node", "cli", "--kanban"])
|
||||
expect(program.opts().kanban).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --update flag", () => {
|
||||
program.parse(["node", "cli", "--update"])
|
||||
expect(program.opts().update).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --tui flag", () => {
|
||||
program.parse(["node", "cli", "--tui"])
|
||||
expect(program.opts().tui).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
@@ -330,11 +478,14 @@ describe("CLI Commands", () => {
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
expect(commandNames).toContain("mcp")
|
||||
expect(commandNames).toContain("kanban")
|
||||
expect(commandNames).toContain("update")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "task")!
|
||||
const historyCmd = program.commands.find((c) => c.name() === "history")!
|
||||
const taskCmd = getCommand("task")
|
||||
const historyCmd = getCommand("history")
|
||||
expect(taskCmd.aliases()).toContain("t")
|
||||
expect(historyCmd.aliases()).toContain("h")
|
||||
})
|
||||
@@ -410,3 +561,42 @@ describe("getProviderModelIdKey", () => {
|
||||
expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
const mockCaptureException = vi.fn().mockResolvedValue(undefined)
|
||||
const mockDispose = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock("@/services/error/ErrorService", () => {
|
||||
return {
|
||||
ErrorService: {
|
||||
get: () => ({
|
||||
captureException: mockCaptureException,
|
||||
dispose: mockDispose,
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("captureUnhandledException", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("captures unhandled exceptions", async () => {
|
||||
const testError = new Error("Test unhandled exception")
|
||||
|
||||
await captureUnhandledException(testError, "unhandledRejection")
|
||||
|
||||
expect(mockCaptureException).toHaveBeenCalledWith(testError, { context: "unhandledRejection" })
|
||||
expect(mockDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not throw if captureException fails", async () => {
|
||||
mockCaptureException.mockRejectedValueOnce(new Error("Capture failed"))
|
||||
|
||||
const testError = new Error("Test unhandled exception")
|
||||
|
||||
await expect(captureUnhandledException(testError, "unhandledRejection")).resolves.not.toThrow()
|
||||
expect(mockCaptureException).toHaveBeenCalledWith(testError, { context: "unhandledRejection" })
|
||||
expect(mockDispose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+384
-30
@@ -2,6 +2,7 @@
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "node:child_process"
|
||||
import { exit } from "node:process"
|
||||
import type { ApiProvider } from "@shared/api"
|
||||
import { Command } from "commander"
|
||||
@@ -9,6 +10,8 @@ import { render } from "ink"
|
||||
import React from "react"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { getHooksEnabledSafe } from "@/core/hooks/hooks-utils"
|
||||
import { setRuntimeHooksDir } from "@/core/storage/disk"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -25,6 +28,7 @@ import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiRe
|
||||
import { version as CLI_VERSION } from "../package.json"
|
||||
import { runAcpMode } from "./acp/index.js"
|
||||
import { App } from "./components/App"
|
||||
import { KanbanMigrationView } from "./components/KanbanMigrationView"
|
||||
import { checkRawModeSupport } from "./context/StdinContext"
|
||||
import { createCliHostBridgeProvider } from "./controllers"
|
||||
import { CliCommentReviewController } from "./controllers/CliCommentReviewController"
|
||||
@@ -32,6 +36,21 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
import {
|
||||
forwardSignalToKanbanProcess,
|
||||
isKanbanCommandAvailable,
|
||||
KANBAN_LAUNCH_COMMAND,
|
||||
KANBAN_SHUTDOWN_TIMEOUT_MS,
|
||||
type KanbanMigrationAction,
|
||||
LEGACY_TUI_FLAG,
|
||||
markKanbanMigrationAnnouncementShown,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncementForCurrentUser,
|
||||
spawnKanbanInstallProcess,
|
||||
spawnKanbanProcess,
|
||||
} from "./utils/kanban"
|
||||
import { addMcpServerShortcut, type McpAddOptions } from "./utils/mcp"
|
||||
import { selectOutputMode } from "./utils/mode-selection"
|
||||
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
|
||||
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
|
||||
@@ -39,6 +58,7 @@ import { readStdinIfPiped } from "./utils/piped"
|
||||
import { runPlainTextTask } from "./utils/plain-text-task"
|
||||
import { applyProviderConfig } from "./utils/provider-config"
|
||||
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
|
||||
import { findMostRecentTaskForWorkspace } from "./utils/task-history"
|
||||
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
@@ -53,18 +73,24 @@ suppressConsoleUnlessVerbose()
|
||||
interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
kanban?: boolean
|
||||
tui?: boolean
|
||||
model?: string
|
||||
verbose?: boolean
|
||||
cwd?: string
|
||||
continue?: boolean
|
||||
config?: string
|
||||
thinking?: boolean | string
|
||||
reasoningEffort?: string
|
||||
maxConsecutiveMistakes?: string
|
||||
yolo?: boolean
|
||||
autoApproveAll?: boolean
|
||||
doubleCheckCompletion?: boolean
|
||||
autoCondense?: boolean
|
||||
timeout?: string
|
||||
json?: boolean
|
||||
stdinWasPiped?: boolean
|
||||
hooksDir?: string
|
||||
}
|
||||
|
||||
let telemetryDisposed = false
|
||||
@@ -133,46 +159,43 @@ function normalizeMaxConsecutiveMistakes(value?: string): number | undefined {
|
||||
function applyTaskOptions(options: TaskOptions): void {
|
||||
// Apply mode flag
|
||||
if (options.plan) {
|
||||
StateManager.get().setGlobalState("mode", "plan")
|
||||
StateManager.get().setSessionOverride("mode", "plan")
|
||||
telemetryService.captureHostEvent("mode_flag", "plan")
|
||||
} else if (options.act) {
|
||||
StateManager.get().setGlobalState("mode", "act")
|
||||
StateManager.get().setSessionOverride("mode", "act")
|
||||
telemetryService.captureHostEvent("mode_flag", "act")
|
||||
}
|
||||
|
||||
// Apply model override if specified
|
||||
if (options.model) {
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") ?? "act") as "act" | "plan"
|
||||
const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider"
|
||||
const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider
|
||||
const modelKey = getProviderModelIdKey(currentProvider, selectedMode)
|
||||
if (modelKey) {
|
||||
StateManager.get().setGlobalState(modelKey, options.model)
|
||||
StateManager.get().setSessionOverride(modelKey, options.model)
|
||||
}
|
||||
telemetryService.captureHostEvent("model_flag", options.model)
|
||||
}
|
||||
|
||||
const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
|
||||
// Set thinking budget based on --thinking flag (boolean or number)
|
||||
let thinkingBudget = 0
|
||||
if (options.thinking) {
|
||||
if (options.thinking !== undefined) {
|
||||
let thinkingBudget = 1024
|
||||
if (typeof options.thinking === "string") {
|
||||
const parsed = Number.parseInt(options.thinking, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
printWarning(`Invalid --thinking value '${options.thinking}'. Using default 1024.`)
|
||||
thinkingBudget = 1024
|
||||
} else {
|
||||
thinkingBudget = parsed
|
||||
}
|
||||
} else {
|
||||
thinkingBudget = 1024
|
||||
}
|
||||
}
|
||||
const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan"
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setGlobalState(thinkingKey, thinkingBudget)
|
||||
})
|
||||
if (options.thinking) {
|
||||
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens"
|
||||
StateManager.get().setSessionOverride(thinkingKey, thinkingBudget)
|
||||
})
|
||||
telemetryService.captureHostEvent("thinking_flag", "true")
|
||||
}
|
||||
|
||||
@@ -180,14 +203,14 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
if (reasoningEffort !== undefined) {
|
||||
setModeScopedState(currentMode, (mode) => {
|
||||
const reasoningKey = mode === "act" ? "actModeReasoningEffort" : "planModeReasoningEffort"
|
||||
StateManager.get().setGlobalState(reasoningKey, reasoningEffort)
|
||||
StateManager.get().setSessionOverride(reasoningKey, reasoningEffort)
|
||||
})
|
||||
telemetryService.captureHostEvent("reasoning_effort_flag", reasoningEffort)
|
||||
}
|
||||
|
||||
const maxConsecutiveMistakes = normalizeMaxConsecutiveMistakes(options.maxConsecutiveMistakes)
|
||||
if (maxConsecutiveMistakes !== undefined) {
|
||||
StateManager.get().setGlobalState("maxConsecutiveMistakes", maxConsecutiveMistakes)
|
||||
StateManager.get().setSessionOverride("maxConsecutiveMistakes", maxConsecutiveMistakes)
|
||||
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
|
||||
}
|
||||
|
||||
@@ -198,11 +221,22 @@ function applyTaskOptions(options: TaskOptions): void {
|
||||
telemetryService.captureHostEvent("yolo_flag", "true")
|
||||
}
|
||||
|
||||
// Set auto-approve-all as a session-scoped override so CLI flag does not
|
||||
// persist user settings to disk.
|
||||
if (options.autoApproveAll) {
|
||||
StateManager.get().setSessionOverride("autoApproveAllToggled", true)
|
||||
telemetryService.captureHostEvent("auto_approve_all_flag", "true")
|
||||
}
|
||||
|
||||
// Set double-check completion based on flag
|
||||
if (options.doubleCheckCompletion) {
|
||||
StateManager.get().setGlobalState("doubleCheckCompletionEnabled", true)
|
||||
StateManager.get().setSessionOverride("doubleCheckCompletionEnabled", true)
|
||||
telemetryService.captureHostEvent("double_check_completion_flag", "true")
|
||||
}
|
||||
|
||||
if (options.autoCondense) {
|
||||
StateManager.get().setSessionOverride("useAutoCondense", true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +267,83 @@ function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
function runKanbanAlias(spawnOptions?: Parameters<typeof spawnKanbanProcess>[0]): void {
|
||||
const launchKanban = () => {
|
||||
const child = spawnKanbanProcess(spawnOptions)
|
||||
activeKanbanProcess = child
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearActiveKanbanProcess()
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${KANBAN_LAUNCH_COMMAND}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearActiveKanbanProcess()
|
||||
exit(resolveProcessExitCode(code, signal))
|
||||
})
|
||||
}
|
||||
|
||||
if (isKanbanCommandAvailable()) {
|
||||
launchKanban()
|
||||
return
|
||||
}
|
||||
|
||||
const installCommand = resolveKanbanInstallCommand()
|
||||
if (!installCommand) {
|
||||
printWarning(
|
||||
`'${KANBAN_LAUNCH_COMMAND}' not found and no supported package manager was detected in PATH (npm, pnpm, bun). Install Kanban globally and try again.`,
|
||||
)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
const installProcess = spawnKanbanInstallProcess(installCommand)
|
||||
|
||||
installProcess.on("error", (error) => {
|
||||
const errorMessage = error instanceof Error ? ` ${error.message}` : ""
|
||||
printWarning(`Failed to run '${installCommand.displayCommand}'.${errorMessage}`)
|
||||
exit(1)
|
||||
})
|
||||
|
||||
installProcess.on("close", (code, signal) => {
|
||||
const installExitCode = resolveProcessExitCode(code, signal)
|
||||
if (installExitCode !== 0) {
|
||||
printWarning(`Failed to install Kanban automatically. Please run '${installCommand.displayCommand}' manually.`)
|
||||
exit(installExitCode)
|
||||
}
|
||||
|
||||
launchKanban()
|
||||
})
|
||||
}
|
||||
|
||||
async function showKanbanMigrationView(): Promise<KanbanMigrationAction> {
|
||||
let selectedAction: KanbanMigrationAction = "exit"
|
||||
|
||||
await runInkApp(
|
||||
React.createElement(KanbanMigrationView, {
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
onSelect: (action: KanbanMigrationAction) => {
|
||||
selectedAction = action
|
||||
},
|
||||
}),
|
||||
async () => {},
|
||||
)
|
||||
|
||||
return selectedAction
|
||||
}
|
||||
|
||||
async function addMcpServer(name: string, targetOrCommand: string[] = [], options: McpAddOptions): Promise<void> {
|
||||
try {
|
||||
const result = await addMcpServerShortcut(name, targetOrCommand, options)
|
||||
const transportLabel = result.transportType === "streamableHttp" ? "http" : result.transportType
|
||||
printInfo(`Added MCP server '${result.serverName}' (${transportLabel}) to ${result.settingsPath}`)
|
||||
} catch (error) {
|
||||
printWarning(error instanceof Error ? error.message : "Failed to add MCP server.")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task in plain text mode (no Ink UI).
|
||||
* Handles auth check, task execution, cleanup, and exit.
|
||||
@@ -299,6 +410,62 @@ let activeContext: CliContext | null = null
|
||||
let isShuttingDown = false
|
||||
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
|
||||
let isPlainTextMode = false
|
||||
let activeKanbanProcess: ChildProcess | null = null
|
||||
let activeKanbanShutdownTimer: NodeJS.Timeout | null = null
|
||||
|
||||
function clearActiveKanbanProcess(): void {
|
||||
activeKanbanProcess = null
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
activeKanbanShutdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function requestKanbanProcessShutdown(signal: NodeJS.Signals): void {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (activeKanbanShutdownTimer) {
|
||||
clearTimeout(activeKanbanShutdownTimer)
|
||||
}
|
||||
|
||||
if (signal === "SIGKILL") {
|
||||
activeKanbanShutdownTimer = null
|
||||
return
|
||||
}
|
||||
|
||||
activeKanbanShutdownTimer = setTimeout(() => {
|
||||
if (!activeKanbanProcess) {
|
||||
return
|
||||
}
|
||||
forwardSignalToKanbanProcess({
|
||||
child: activeKanbanProcess,
|
||||
signal: "SIGKILL",
|
||||
})
|
||||
}, KANBAN_SHUTDOWN_TIMEOUT_MS)
|
||||
activeKanbanShutdownTimer.unref?.()
|
||||
}
|
||||
|
||||
function resolveProcessExitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
||||
if (code !== null) {
|
||||
return code
|
||||
}
|
||||
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130
|
||||
case "SIGTERM":
|
||||
return 143
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for stdout to fully drain before exiting.
|
||||
@@ -316,8 +483,55 @@ async function drainStdout(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function captureUnhandledException(reason: Error, context: string) {
|
||||
try {
|
||||
// ErrorService may not be initialized yet (e.g., error occurred before initializeCli())
|
||||
// so we guard with a try/get pattern rather than letting ErrorService.get() throw
|
||||
let errorService: ErrorService | null = null
|
||||
try {
|
||||
errorService = ErrorService.get()
|
||||
} catch {
|
||||
// ErrorService not yet initialized; skip capture
|
||||
}
|
||||
if (errorService) {
|
||||
await errorService.captureException(reason, { context })
|
||||
// dispose flushes any pending error captures to ensure they're sent before the process exits
|
||||
return errorService.dispose()
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during shutdown to avoid an infinite loop
|
||||
Logger.info("Error capturing unhandled exception. Proceeding with shutdown.")
|
||||
}
|
||||
}
|
||||
|
||||
const EXIT_TIMEOUT_MS = 3000
|
||||
function onUnhandledException(reason: unknown, context: string) {
|
||||
Logger.error("Unhandled exception:", reason)
|
||||
const finalError = reason instanceof Error ? reason : new Error(String(reason))
|
||||
|
||||
restoreConsole()
|
||||
console.error(finalError)
|
||||
|
||||
setTimeout(() => process.exit(1), EXIT_TIMEOUT_MS)
|
||||
|
||||
captureUnhandledException(finalError, context).finally(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
function setupSignalHandlers() {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (activeKanbanProcess) {
|
||||
if (isShuttingDown) {
|
||||
requestKanbanProcessShutdown("SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
isShuttingDown = true
|
||||
requestKanbanProcessShutdown(signal === "SIGTERM" ? "SIGTERM" : "SIGINT")
|
||||
return
|
||||
}
|
||||
|
||||
if (isShuttingDown) {
|
||||
// Force exit on second signal
|
||||
process.exit(1)
|
||||
@@ -353,7 +567,11 @@ function setupSignalHandlers() {
|
||||
} catch {
|
||||
// StateManager may not be initialized yet
|
||||
}
|
||||
await ErrorService.get().dispose()
|
||||
try {
|
||||
await ErrorService.get().dispose()
|
||||
} catch {
|
||||
// ErrorService may not be initialized yet
|
||||
}
|
||||
await disposeTelemetryServices()
|
||||
}
|
||||
} catch {
|
||||
@@ -375,9 +593,14 @@ function setupSignalHandlers() {
|
||||
Logger.info("Suppressed unhandled rejection due to abort:", message)
|
||||
return
|
||||
}
|
||||
// For other unhandled rejections, log to file via Logger (if available)
|
||||
|
||||
// For other unhandled rejections, capture the exception and log to file via Logger (if available)
|
||||
// This won't show in terminal but will be in log files for debugging
|
||||
Logger.error("Unhandled rejection:", reason)
|
||||
onUnhandledException(reason, "unhandledRejection")
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (reason: unknown) => {
|
||||
onUnhandledException(reason, "uncaughtException")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,6 +617,7 @@ interface CliContext {
|
||||
interface InitOptions {
|
||||
config?: string
|
||||
cwd?: string
|
||||
hooksDir?: string
|
||||
verbose?: boolean
|
||||
enableAuth?: boolean
|
||||
}
|
||||
@@ -403,6 +627,7 @@ interface InitOptions {
|
||||
*/
|
||||
async function initializeCli(options: InitOptions): Promise<CliContext> {
|
||||
const workspacePath = options.cwd || process.cwd()
|
||||
setRuntimeHooksDir(options.hooksDir)
|
||||
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: workspacePath,
|
||||
@@ -504,6 +729,11 @@ async function runTask(prompt: string, options: TaskOptions & { images?: string[
|
||||
// Task without prompt starts in interactive mode
|
||||
telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive")
|
||||
|
||||
// Capture piped stdin telemetry now that HostProvider is initialized
|
||||
if (options.stdinWasPiped) {
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
}
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
@@ -596,7 +826,7 @@ async function showConfig(options: { config?: string }) {
|
||||
dataDir: ctx.dataDir,
|
||||
globalState: stateManager.getAllGlobalStateEntries(),
|
||||
workspaceState: stateManager.getAllWorkspaceStateEntries(),
|
||||
hooksEnabled: true,
|
||||
hooksEnabled: getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled")),
|
||||
skillsEnabled: true,
|
||||
isRawModeSupported: checkRawModeSupport(),
|
||||
}),
|
||||
@@ -728,6 +958,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
|
||||
.option("--auto-approve-all", "Enable auto-approve all actions while keeping interactive mode")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
@@ -738,6 +969,8 @@ program
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.action((prompt, options) => {
|
||||
if (options.taskId) {
|
||||
@@ -773,6 +1006,18 @@ program
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(runAuth)
|
||||
|
||||
const mcpCommand = program.command("mcp").description("Manage MCP servers")
|
||||
|
||||
mcpCommand
|
||||
.command("add")
|
||||
.description("Add an MCP server shortcut to cline_mcp_settings.json")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument("[targetOrCommand...]", "For stdio: use -- <command> [args]. For http/sse: provide <url>.")
|
||||
.option("--type <type>", "Transport type: stdio (default), http, or sse", "stdio")
|
||||
.option("-c, --cwd <path>", "Working directory for config resolution")
|
||||
.option("--config <path>", "Path to Cline configuration directory")
|
||||
.action(addMcpServer)
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show Cline CLI version number")
|
||||
@@ -782,7 +1027,12 @@ program
|
||||
.command("update")
|
||||
.description("Check for updates and install if available")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
.action((options) => checkForUpdates(CLI_VERSION, { verbose: options.verbose, includeKanban: true }))
|
||||
|
||||
program
|
||||
.command("kanban")
|
||||
.description(`Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.action(() => runKanbanAlias())
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
@@ -808,8 +1058,8 @@ function findTaskInHistory(taskId: string): HistoryItem | null {
|
||||
* Resume an existing task by ID
|
||||
* Loads the task and optionally prefills the input with a prompt
|
||||
*/
|
||||
async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }, existingContext?: CliContext) {
|
||||
const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true }))
|
||||
|
||||
// Validate task exists
|
||||
const historyItem = findTaskInHistory(taskId)
|
||||
@@ -822,6 +1072,11 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
|
||||
telemetryService.captureHostEvent("resume_task_command", options.initialPrompt ? "with_prompt" : "interactive")
|
||||
|
||||
// Capture piped stdin telemetry now that HostProvider is initialized
|
||||
if (options.stdinWasPiped) {
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
}
|
||||
|
||||
// Apply shared task options (mode, model, thinking, yolo)
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
@@ -856,16 +1111,35 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt
|
||||
)
|
||||
}
|
||||
|
||||
async function continueTask(options: TaskOptions) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
const historyItem = findMostRecentTaskForWorkspace(StateManager.get().getGlobalStateKey("taskHistory"), ctx.workspacePath)
|
||||
|
||||
if (!historyItem) {
|
||||
printWarning(`No previous task found for ${ctx.workspacePath}`)
|
||||
printInfo("Start a new task or use 'cline history' to browse previous tasks.")
|
||||
await disposeCliContext(ctx)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
return resumeTask(historyItem.id, options, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show welcome prompt and wait for user input
|
||||
* If auth is not configured, show auth flow first
|
||||
*/
|
||||
async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) {
|
||||
async function showWelcome(options: TaskOptions) {
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
|
||||
// Check if auth is configured
|
||||
const hasAuth = await isAuthConfigured()
|
||||
|
||||
// Apply CLI task options in interactive startup too, so flags like
|
||||
// --auto-approve-all and --yolo affect the initial TUI state.
|
||||
applyTaskOptions(options)
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
let hadError = false
|
||||
|
||||
await runInkApp(
|
||||
@@ -895,6 +1169,7 @@ program
|
||||
.option("-a, --act", "Run in act mode")
|
||||
.option("-p, --plan", "Run in plan mode")
|
||||
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
|
||||
.option("--auto-approve-all", "Enable auto-approve all actions while keeping interactive mode")
|
||||
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
|
||||
.option("-m, --model <model>", "Model to use for the task")
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
@@ -905,14 +1180,46 @@ program
|
||||
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
|
||||
.option("--json", "Output messages as JSON instead of styled text")
|
||||
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
|
||||
.option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation")
|
||||
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
|
||||
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
|
||||
.option("--update", "Check for updates and install if available")
|
||||
.option("--kanban", `Run ${KANBAN_LAUNCH_COMMAND}`)
|
||||
.option("--tui", "Open the legacy terminal UI instead of the kanban experience")
|
||||
.option("-T, --taskId <id>", "Resume an existing task by ID")
|
||||
.option("--continue", "Resume the most recent task from the current working directory")
|
||||
.action(async (prompt, options) => {
|
||||
if (options.kanban && options.tui) {
|
||||
printWarning(`Use either --kanban or ${LEGACY_TUI_FLAG}, not both.`)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.update) {
|
||||
if (prompt || options.taskId || options.continue || options.kanban || options.tui || options.acp) {
|
||||
printWarning("Use --update without a prompt or task flags.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
await checkForUpdates(CLI_VERSION, { verbose: options.verbose, includeKanban: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.kanban) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
// Check for ACP mode first - this takes precedence over everything else
|
||||
if (options.acp) {
|
||||
await runAcpMode({
|
||||
config: options.config,
|
||||
cwd: options.cwd,
|
||||
hooksDir: options.hooksDir,
|
||||
verbose: options.verbose,
|
||||
})
|
||||
return
|
||||
@@ -927,6 +1234,53 @@ program
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
if (
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt,
|
||||
stdinWasPiped,
|
||||
taskId: options.taskId,
|
||||
continue: options.continue,
|
||||
tui: options.tui,
|
||||
})
|
||||
) {
|
||||
let migrationAction: "kanban" | "exit" = "kanban"
|
||||
const ctx = await initializeCli({ ...options, enableAuth: true })
|
||||
try {
|
||||
if (await shouldShowKanbanMigrationAnnouncementForCurrentUser()) {
|
||||
migrationAction = await showKanbanMigrationView()
|
||||
await markKanbanMigrationAnnouncementShown()
|
||||
}
|
||||
} finally {
|
||||
await disposeCliContext(ctx)
|
||||
}
|
||||
|
||||
if (migrationAction === "exit") {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
runKanbanAlias({ cwd: options.cwd })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.taskId && options.continue) {
|
||||
printWarning("Use either --taskId or --continue, not both.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (options.continue) {
|
||||
if (prompt) {
|
||||
printWarning("Use --continue without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
if (stdinWasPiped) {
|
||||
printWarning("Use --continue without piped input.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
await continueTask(options)
|
||||
return
|
||||
}
|
||||
|
||||
// Error if stdin was piped but empty AND no prompt was provided
|
||||
// This handles:
|
||||
// - `echo "" | cline` -> error (empty stdin, no prompt)
|
||||
@@ -947,8 +1301,6 @@ program
|
||||
effectivePrompt = stdinInput
|
||||
}
|
||||
|
||||
telemetryService.captureHostEvent("piped", "detached")
|
||||
|
||||
// Debug: show that we received piped input
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`)
|
||||
@@ -975,4 +1327,6 @@ program
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
if (process.env.VITEST !== "true") {
|
||||
program.parse()
|
||||
}
|
||||
|
||||
@@ -15,3 +15,13 @@ export function isMouseEscapeSequence(input: string): boolean {
|
||||
// They contain [< followed by numbers, semicolons, and end with M or m
|
||||
return input.includes("[<") && /\[<\d+;\d+;\d+[Mm]/.test(input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink's key metadata can be inconsistent across platforms/test environments for Enter.
|
||||
* In particular, some Windows CI/test runs surface Enter as raw "\r" input without
|
||||
* setting key.return. Treat either representation as Enter so keyboard handlers remain
|
||||
* stable in production and in tests across platforms.
|
||||
*/
|
||||
export function isEnterKey(input: string, key: { return?: boolean }): boolean {
|
||||
return key.return === true || input === "\r" || input === "\n"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
buildKanbanInstallSpawnOptions,
|
||||
buildKanbanSpawnOptions,
|
||||
forwardSignalToKanbanProcess,
|
||||
hasUsedLegacyCli,
|
||||
isKanbanCommandAvailable,
|
||||
resolveKanbanInstallCommand,
|
||||
shouldDetachKanbanProcess,
|
||||
shouldLaunchKanbanByDefault,
|
||||
shouldShowKanbanMigrationAnnouncement,
|
||||
} from "./kanban"
|
||||
|
||||
describe("shouldLaunchKanbanByDefault", () => {
|
||||
it("launches kanban for a bare interactive run", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not launch kanban when a prompt is provided", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
prompt: "fix the tests",
|
||||
stdinWasPiped: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when stdin is piped", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("does not launch kanban when the legacy tui is requested", () => {
|
||||
expect(
|
||||
shouldLaunchKanbanByDefault({
|
||||
stdinWasPiped: false,
|
||||
tui: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasUsedLegacyCli", () => {
|
||||
it("treats task history as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 1,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("treats configured auth as legacy usage", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("skips the announcement for fresh installs", () => {
|
||||
expect(
|
||||
hasUsedLegacyCli({
|
||||
taskHistoryCount: 0,
|
||||
isNewUser: true,
|
||||
welcomeViewCompleted: undefined,
|
||||
hasConfiguredAuth: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban process launch", () => {
|
||||
it("detaches the kanban process on unix-like platforms", () => {
|
||||
expect(shouldDetachKanbanProcess("darwin")).toBe(true)
|
||||
expect(shouldDetachKanbanProcess("linux")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps the kanban process attached on windows", () => {
|
||||
expect(shouldDetachKanbanProcess("win32")).toBe(false)
|
||||
})
|
||||
|
||||
it("uses a detached process group by default on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not detach the process on windows", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for command launches", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not set shell mode on unix-like platforms", () => {
|
||||
expect(buildKanbanSpawnOptions({}, "darwin")).not.toHaveProperty("shell")
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command availability", () => {
|
||||
it("returns false when PATH is empty", () => {
|
||||
expect(isKanbanCommandAvailable({ PATH: "" }, "darwin")).toBe(false)
|
||||
})
|
||||
|
||||
it("detects the kanban command in PATH", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-cli-test-"))
|
||||
const commandPath = join(tempDirectory, process.platform === "win32" ? "kanban.cmd" : "kanban")
|
||||
writeFileSync(commandPath, process.platform === "win32" ? "@echo off\r\necho ok\r\n" : "#!/bin/sh\necho ok\n")
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(commandPath, 0o755)
|
||||
}
|
||||
|
||||
try {
|
||||
expect(isKanbanCommandAvailable({ PATH: tempDirectory }, process.platform)).toBe(true)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban install process launch", () => {
|
||||
it("does not detach the install process on unix-like platforms", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "darwin")).toMatchObject({
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("enables shell mode on windows for npm.cmd launches", () => {
|
||||
expect(buildKanbanInstallSpawnOptions({}, "win32")).toMatchObject({
|
||||
shell: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban installer resolution", () => {
|
||||
it("prefers npm when available", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "npm"), "#!/bin/sh\necho npm\n")
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "npm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
expect(resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")?.packageManager).toBe("npm")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to pnpm when npm is unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "pnpm"), "#!/bin/sh\necho pnpm\n")
|
||||
chmodSync(join(tempDirectory, "pnpm"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("pnpm")
|
||||
expect(installer?.displayCommand).toBe("pnpm add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("falls back to bun when npm and pnpm are unavailable", () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-installer-test-"))
|
||||
writeFileSync(join(tempDirectory, "bun"), "#!/bin/sh\necho bun\n")
|
||||
chmodSync(join(tempDirectory, "bun"), 0o755)
|
||||
|
||||
try {
|
||||
const installer = resolveKanbanInstallCommand({ PATH: tempDirectory }, "darwin")
|
||||
expect(installer?.packageManager).toBe("bun")
|
||||
expect(installer?.displayCommand).toBe("bun add -g kanban@latest")
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns null when no supported package manager is available", () => {
|
||||
expect(resolveKanbanInstallCommand({ PATH: "" }, "darwin")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("forwardSignalToKanbanProcess", () => {
|
||||
it("signals the detached kanban process group on unix-like platforms", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGINT",
|
||||
platform: "darwin",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).toHaveBeenCalledWith(-4321, "SIGINT")
|
||||
expect(child.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("signals the child process directly on windows", () => {
|
||||
const killProcess = vi.fn()
|
||||
const child = {
|
||||
pid: 4321,
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
forwardSignalToKanbanProcess({
|
||||
child,
|
||||
signal: "SIGTERM",
|
||||
platform: "win32",
|
||||
killProcess,
|
||||
})
|
||||
|
||||
expect(killProcess).not.toHaveBeenCalled()
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldShowKanbanMigrationAnnouncement", () => {
|
||||
it("shows the announcement once for legacy users", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: false,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not show the announcement twice", () => {
|
||||
expect(
|
||||
shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: true,
|
||||
hasUsedLegacyCli: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { type ChildProcess, type SpawnOptions, spawn } from "node:child_process"
|
||||
import { accessSync, constants as fsConstants } from "node:fs"
|
||||
import { delimiter, extname, join } from "node:path"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { checkAnyProviderConfigured } from "./auth"
|
||||
|
||||
export const KANBAN_LAUNCH_COMMAND = "kanban"
|
||||
export const KANBAN_SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
export const LEGACY_TUI_FLAG = "--tui"
|
||||
export type KanbanMigrationAction = "kanban" | "exit"
|
||||
type KanbanInstaller = "npm" | "pnpm" | "bun"
|
||||
|
||||
interface KanbanInstallCommand {
|
||||
packageManager: KanbanInstaller
|
||||
command: string
|
||||
args: readonly string[]
|
||||
displayCommand: string
|
||||
}
|
||||
|
||||
interface SignalableKanbanProcess {
|
||||
pid?: number
|
||||
kill: (signal?: NodeJS.Signals | number) => boolean
|
||||
}
|
||||
|
||||
function getKanbanCommand(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === "win32" ? "kanban.cmd" : "kanban"
|
||||
}
|
||||
|
||||
function getPackageManagerCommand(packageManager: KanbanInstaller, platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform !== "win32") {
|
||||
return packageManager
|
||||
}
|
||||
|
||||
return packageManager === "bun" ? "bun" : `${packageManager}.cmd`
|
||||
}
|
||||
|
||||
const KANBAN_INSTALL_COMMANDS: ReadonlyArray<Omit<KanbanInstallCommand, "displayCommand">> = [
|
||||
{
|
||||
packageManager: "npm",
|
||||
command: "npm",
|
||||
args: ["install", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "pnpm",
|
||||
command: "pnpm",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
{
|
||||
packageManager: "bun",
|
||||
command: "bun",
|
||||
args: ["add", "-g", "kanban@latest"],
|
||||
},
|
||||
]
|
||||
|
||||
function toDisplayCommand(command: string, args: readonly string[]): string {
|
||||
return `${command} ${args.join(" ")}`
|
||||
}
|
||||
|
||||
export function shouldDetachKanbanProcess(platform: NodeJS.Platform = process.platform): boolean {
|
||||
return platform !== "win32"
|
||||
}
|
||||
|
||||
export function buildKanbanSpawnOptions(options: SpawnOptions = {}, platform: NodeJS.Platform = process.platform): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKanbanInstallSpawnOptions(
|
||||
options: SpawnOptions = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SpawnOptions {
|
||||
return {
|
||||
stdio: "inherit",
|
||||
detached: false,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnKanbanProcess(options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(getKanbanCommand(), [], buildKanbanSpawnOptions(options))
|
||||
}
|
||||
|
||||
export function spawnKanbanInstallProcess(installCommand: KanbanInstallCommand, options: SpawnOptions = {}): ChildProcess {
|
||||
return spawn(
|
||||
getPackageManagerCommand(installCommand.packageManager),
|
||||
[...installCommand.args],
|
||||
buildKanbanInstallSpawnOptions(options),
|
||||
)
|
||||
}
|
||||
|
||||
function getPathEntries(env: NodeJS.ProcessEnv): string[] {
|
||||
const pathValue = env.PATH ?? env.Path ?? env.path
|
||||
if (!pathValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pathValue
|
||||
.split(delimiter)
|
||||
.map((entry) => entry.trim().replace(/^"(.*)"$/u, "$1"))
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
function pathExists(candidatePath: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
accessSync(candidatePath, fsConstants.F_OK)
|
||||
} else {
|
||||
accessSync(candidatePath, fsConstants.X_OK)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isCommandAvailable(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const commandHasExtension = extname(command).length > 0
|
||||
const pathExtensions =
|
||||
platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter((ext) => ext.length > 0) : []
|
||||
|
||||
for (const pathEntry of getPathEntries(env)) {
|
||||
const commandPath = join(pathEntry, command)
|
||||
if (pathExists(commandPath, platform)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!commandHasExtension && platform === "win32") {
|
||||
for (const extension of pathExtensions) {
|
||||
if (pathExists(`${commandPath}${extension}`, platform)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isKanbanCommandAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
return isCommandAvailable(getKanbanCommand(platform), env, platform)
|
||||
}
|
||||
|
||||
export function resolveKanbanInstallCommand(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): KanbanInstallCommand | null {
|
||||
for (const installCommand of KANBAN_INSTALL_COMMANDS) {
|
||||
if (isCommandAvailable(installCommand.command, env, platform)) {
|
||||
return {
|
||||
...installCommand,
|
||||
displayCommand: toDisplayCommand(installCommand.command, installCommand.args),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function forwardSignalToKanbanProcess(options: {
|
||||
child: SignalableKanbanProcess
|
||||
signal: NodeJS.Signals
|
||||
platform?: NodeJS.Platform
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals | number) => boolean
|
||||
}): void {
|
||||
if (options.child.pid == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldDetachKanbanProcess(options.platform)) {
|
||||
try {
|
||||
;(options.killProcess ?? process.kill)(-options.child.pid, options.signal)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options.child.kill(options.signal)
|
||||
}
|
||||
|
||||
export function shouldLaunchKanbanByDefault(options: {
|
||||
prompt?: string
|
||||
stdinWasPiped: boolean
|
||||
taskId?: string
|
||||
continue?: boolean
|
||||
tui?: boolean
|
||||
}): boolean {
|
||||
return !options.prompt && !options.stdinWasPiped && !options.taskId && !options.continue && !options.tui
|
||||
}
|
||||
|
||||
export function hasUsedLegacyCli(options: {
|
||||
taskHistoryCount: number
|
||||
isNewUser: boolean
|
||||
welcomeViewCompleted: boolean | undefined
|
||||
hasConfiguredAuth: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
options.taskHistoryCount > 0 ||
|
||||
options.isNewUser === false ||
|
||||
options.welcomeViewCompleted !== undefined ||
|
||||
options.hasConfiguredAuth
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowKanbanMigrationAnnouncement(options: {
|
||||
announcementShown: boolean
|
||||
hasUsedLegacyCli: boolean
|
||||
}): boolean {
|
||||
return !options.announcementShown && options.hasUsedLegacyCli
|
||||
}
|
||||
|
||||
export async function shouldShowKanbanMigrationAnnouncementForCurrentUser(): Promise<boolean> {
|
||||
const stateManager = StateManager.get()
|
||||
const hasConfiguredAuth = await checkAnyProviderConfigured()
|
||||
const hasUsedLegacy = hasUsedLegacyCli({
|
||||
taskHistoryCount: stateManager.getGlobalStateKey("taskHistory")?.length ?? 0,
|
||||
isNewUser: stateManager.getGlobalStateKey("isNewUser"),
|
||||
welcomeViewCompleted: stateManager.getGlobalStateKey("welcomeViewCompleted"),
|
||||
hasConfiguredAuth,
|
||||
})
|
||||
|
||||
return shouldShowKanbanMigrationAnnouncement({
|
||||
announcementShown: stateManager.getGlobalStateKey("cliKanbanMigrationAnnouncementShown"),
|
||||
hasUsedLegacyCli: hasUsedLegacy,
|
||||
})
|
||||
}
|
||||
|
||||
export async function markKanbanMigrationAnnouncementShown(): Promise<void> {
|
||||
const stateManager = StateManager.get()
|
||||
stateManager.setGlobalState("cliKanbanMigrationAnnouncementShown", true)
|
||||
await stateManager.flushPendingState()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { addMcpServerShortcut } from "./mcp"
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
async function createTempConfigDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-mcp-test-"))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
type McpSettingsFile = {
|
||||
mcpServers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
async function readMcpSettings(configDir: string): Promise<McpSettingsFile> {
|
||||
const settingsPath = path.join(configDir, "data", "settings", "cline_mcp_settings.json")
|
||||
return JSON.parse(await fs.readFile(settingsPath, "utf-8")) as McpSettingsFile
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("addMcpServerShortcut", () => {
|
||||
it("writes stdio servers with type=stdio", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("kanban", ["kanban", "mcp"], { config: configDir })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.kanban).toEqual({
|
||||
command: "kanban",
|
||||
args: ["mcp"],
|
||||
type: "stdio",
|
||||
})
|
||||
})
|
||||
|
||||
it("maps --type http to streamableHttp", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir, type: "http" })
|
||||
const settings = await readMcpSettings(configDir)
|
||||
|
||||
expect(settings.mcpServers.linear).toEqual({
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
type: "streamableHttp",
|
||||
})
|
||||
})
|
||||
|
||||
it("errors when URL is provided without --type http", async () => {
|
||||
const configDir = await createTempConfigDir()
|
||||
|
||||
await expect(addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir })).rejects.toThrow(
|
||||
"Use --type http",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { getMcpSettingsFilePath } from "@/core/storage/disk"
|
||||
import { ServerConfigSchema } from "@/services/mcp/schemas"
|
||||
import { initializeCliContext } from "../vscode-context"
|
||||
|
||||
export interface McpAddOptions {
|
||||
type?: string
|
||||
config?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type McpAddTransportType = "stdio" | "streamableHttp" | "sse"
|
||||
|
||||
export interface AddMcpServerResult {
|
||||
serverName: string
|
||||
transportType: McpAddTransportType
|
||||
settingsPath: string
|
||||
}
|
||||
|
||||
function normalizeMcpTransportType(value?: string): McpAddTransportType {
|
||||
const normalized = (value || "stdio").trim().toLowerCase()
|
||||
|
||||
switch (normalized) {
|
||||
case "stdio":
|
||||
return "stdio"
|
||||
case "http":
|
||||
case "streamable-http":
|
||||
case "streamablehttp":
|
||||
return "streamableHttp"
|
||||
case "sse":
|
||||
return "sse"
|
||||
default:
|
||||
throw new Error(`Invalid MCP transport type '${value}'. Valid values: stdio, http, sse.`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseMcpSettings(content: string, settingsPath: string): Record<string, unknown> {
|
||||
const trimmedContent = content.trim()
|
||||
if (!trimmedContent) {
|
||||
return { mcpServers: {} }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON in ${settingsPath}. Please fix the file and try again.`)
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected a JSON object.`)
|
||||
}
|
||||
|
||||
const settings = parsed as Record<string, unknown>
|
||||
if (settings.mcpServers === undefined) {
|
||||
settings.mcpServers = {}
|
||||
}
|
||||
|
||||
if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) {
|
||||
throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected 'mcpServers' to be an object.`)
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
function createMcpServerConfig(targetOrCommand: string[], transportType: McpAddTransportType): Record<string, unknown> {
|
||||
if (transportType === "stdio") {
|
||||
if (targetOrCommand.length < 1) {
|
||||
throw new Error("Missing stdio command. Example: cline mcp add kanban -- kanban mcp")
|
||||
}
|
||||
|
||||
// Guard against common mistake:
|
||||
// `cline mcp add <name> <url>` without `--type http`
|
||||
if (targetOrCommand.length === 1) {
|
||||
const [value] = targetOrCommand
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
||||
throw new Error(
|
||||
`Looks like you provided a URL for '${value}'. Use --type http, for example: cline mcp add <name> ${value} --type http`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Looks like you provided a URL")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [command, ...args] = targetOrCommand
|
||||
const config: Record<string, unknown> = {
|
||||
command,
|
||||
type: "stdio",
|
||||
}
|
||||
|
||||
if (args.length > 0) {
|
||||
config.args = args
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
if (targetOrCommand.length !== 1) {
|
||||
throw new Error(
|
||||
"HTTP/SSE MCP servers require exactly one URL. Example: cline mcp add linear https://mcp.linear.app/mcp --type http",
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: targetOrCommand[0],
|
||||
type: transportType,
|
||||
}
|
||||
|
||||
ServerConfigSchema.parse(config)
|
||||
return config
|
||||
}
|
||||
|
||||
export async function addMcpServerShortcut(
|
||||
name: string,
|
||||
targetOrCommand: string[] = [],
|
||||
options: McpAddOptions,
|
||||
): Promise<AddMcpServerResult> {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
throw new Error("Server name is required.")
|
||||
}
|
||||
|
||||
const transportType = normalizeMcpTransportType(options.type)
|
||||
|
||||
const { DATA_DIR } = initializeCliContext({
|
||||
clineDir: options.config,
|
||||
workspaceDir: options.cwd || process.cwd(),
|
||||
})
|
||||
|
||||
const settingsDirectoryPath = path.join(DATA_DIR, "settings")
|
||||
await fs.mkdir(settingsDirectoryPath, { recursive: true })
|
||||
const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath)
|
||||
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = parseMcpSettings(content, settingsPath)
|
||||
const mcpServers = settings.mcpServers as Record<string, unknown>
|
||||
|
||||
if (mcpServers[trimmedName]) {
|
||||
throw new Error(`An MCP server named '${trimmedName}' already exists.`)
|
||||
}
|
||||
|
||||
const serverConfig = createMcpServerConfig(targetOrCommand, transportType)
|
||||
mcpServers[trimmedName] = serverConfig
|
||||
|
||||
await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8")
|
||||
|
||||
return {
|
||||
serverName: trimmedName,
|
||||
transportType,
|
||||
settingsPath,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { filterCommands, getStandaloneSlashCommandToExecute } from "./slash-commands"
|
||||
|
||||
const createCommand = (name: string): SlashCommandInfo => ({
|
||||
name,
|
||||
description: `${name} command`,
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
})
|
||||
|
||||
describe("filterCommands", () => {
|
||||
it("prioritizes exact matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("help"), createCommand("history"), createCommand("q")]
|
||||
|
||||
const result = filterCommands(commands, "q")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("q")
|
||||
})
|
||||
|
||||
it("prioritizes prefix matches ahead of fuzzy matches", () => {
|
||||
const commands = [createCommand("history"), createCommand("help"), createCommand("exit")]
|
||||
|
||||
const result = filterCommands(commands, "hi")
|
||||
|
||||
expect(result.map((command) => command.name)[0]).toBe("history")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getStandaloneSlashCommandToExecute", () => {
|
||||
it("ignores standalone execution when slash menu is visible", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/q",
|
||||
inSlashMode: true,
|
||||
hasSlashMenu: true,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("returns standalone command when enter should execute it directly", () => {
|
||||
expect(
|
||||
getStandaloneSlashCommandToExecute({
|
||||
prompt: "/exit",
|
||||
inSlashMode: false,
|
||||
hasSlashMenu: false,
|
||||
hasPendingAsk: false,
|
||||
isSpinnerActive: false,
|
||||
}),
|
||||
).toBe("exit")
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { SlashCommandInfo } from "@shared/proto/cline/slash"
|
||||
import { CLI_ONLY_COMMANDS } from "@shared/slashCommands"
|
||||
import { fuzzyFilter } from "./fuzzy-search"
|
||||
|
||||
export interface SlashQueryInfo {
|
||||
@@ -17,12 +18,29 @@ export interface VisibleWindow<T> {
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
export interface StandaloneSlashCommandExecutionInput {
|
||||
prompt: string
|
||||
inSlashMode: boolean
|
||||
hasSlashMenu: boolean
|
||||
hasPendingAsk: boolean
|
||||
isSpinnerActive: boolean
|
||||
}
|
||||
|
||||
export function createCliOnlySlashCommands(): SlashCommandInfo[] {
|
||||
return CLI_ONLY_COMMANDS.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description || "",
|
||||
section: cmd.section || "default",
|
||||
cliCompatible: true,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate visible window for a scrollable list menu.
|
||||
* Centers the selected item in the visible window when possible.
|
||||
* Returns the visible items and the start index for selection tracking.
|
||||
*/
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible: number = 5): VisibleWindow<T> {
|
||||
export function getVisibleWindow<T>(items: T[], selectedIndex: number, maxVisible = 5): VisibleWindow<T> {
|
||||
if (items.length <= maxVisible) {
|
||||
return { items, startIndex: 0 }
|
||||
}
|
||||
@@ -91,6 +109,42 @@ export function extractSlashQuery(text: string, cursorPosition?: number): SlashQ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a standalone slash command (for example "/q" or "/exit")
|
||||
* that should be executed immediately when enter is pressed.
|
||||
*/
|
||||
export function getStandaloneSlashCommandName(text: string): string | null {
|
||||
const match = text.trim().match(/^\/([a-zA-Z0-9_.-]+)$/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether pressing Enter should execute a standalone CLI slash command.
|
||||
* This keeps ChatView's key handling deterministic and easy to test.
|
||||
*/
|
||||
export function getStandaloneSlashCommandToExecute({
|
||||
prompt,
|
||||
inSlashMode,
|
||||
hasSlashMenu,
|
||||
hasPendingAsk,
|
||||
isSpinnerActive,
|
||||
}: StandaloneSlashCommandExecutionInput): string | null {
|
||||
const standaloneSlashCommand = getStandaloneSlashCommandName(prompt)
|
||||
if (!standaloneSlashCommand) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasPendingAsk || isSpinnerActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (inSlashMode && hasSlashMenu) {
|
||||
return null
|
||||
}
|
||||
|
||||
return standaloneSlashCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter commands using fuzzy matching
|
||||
*/
|
||||
@@ -98,7 +152,26 @@ export function filterCommands(commands: SlashCommandInfo[], query: string): Sla
|
||||
if (!query) {
|
||||
return commands
|
||||
}
|
||||
return fuzzyFilter(commands, query, (cmd) => cmd.name)
|
||||
|
||||
const normalizedQuery = query.toLowerCase()
|
||||
const exactMatches: SlashCommandInfo[] = []
|
||||
const prefixMatches: SlashCommandInfo[] = []
|
||||
const remaining: SlashCommandInfo[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
const normalizedName = command.name.toLowerCase()
|
||||
if (normalizedName === normalizedQuery) {
|
||||
exactMatches.push(command)
|
||||
continue
|
||||
}
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
prefixMatches.push(command)
|
||||
continue
|
||||
}
|
||||
remaining.push(command)
|
||||
}
|
||||
|
||||
return [...exactMatches, ...prefixMatches, ...fuzzyFilter(remaining, query, (cmd) => cmd.name)]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { findMostRecentTaskForWorkspace } from "./task-history"
|
||||
|
||||
describe("findMostRecentTaskForWorkspace", () => {
|
||||
it("returns the newest matching task for the workspace", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "older",
|
||||
ts: 100,
|
||||
task: "Older task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/repo",
|
||||
},
|
||||
{
|
||||
id: "newer",
|
||||
ts: 200,
|
||||
task: "Newer task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/repo",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("newer")
|
||||
})
|
||||
|
||||
it("falls back to shadowGitConfigWorkTree for older tasks", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "legacy",
|
||||
ts: 200,
|
||||
task: "Legacy task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
shadowGitConfigWorkTree: "/repo",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("legacy")
|
||||
})
|
||||
|
||||
it("returns null when there is no match", () => {
|
||||
const result = findMostRecentTaskForWorkspace(
|
||||
[
|
||||
{
|
||||
id: "other",
|
||||
ts: 200,
|
||||
task: "Other task",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
totalCost: 0,
|
||||
cwdOnTaskInitialization: "/other",
|
||||
},
|
||||
],
|
||||
"/repo",
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { arePathsEqual } from "@/utils/path"
|
||||
|
||||
export function findMostRecentTaskForWorkspace(
|
||||
taskHistory: HistoryItem[] | undefined,
|
||||
workspacePath: string,
|
||||
): HistoryItem | null {
|
||||
if (!taskHistory?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
[...taskHistory]
|
||||
.filter((item) => {
|
||||
if (!item.ts || !item.task) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
(item.cwdOnTaskInitialization && arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) ||
|
||||
(item.shadowGitConfigWorkTree && arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)),
|
||||
)
|
||||
})
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.at(0) ?? null
|
||||
)
|
||||
}
|
||||
+198
-70
@@ -1,9 +1,10 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { type ChildProcess, spawn, spawnSync } from "node:child_process"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { exit } from "node:process"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { printInfo, printWarning } from "./display"
|
||||
import { printInfo, printSuccess, printWarning } from "./display"
|
||||
import { resolveKanbanInstallCommand, spawnKanbanInstallProcess } from "./kanban"
|
||||
|
||||
export enum PackageManager {
|
||||
NPM = "npm",
|
||||
@@ -19,6 +20,11 @@ interface InstallationInfo {
|
||||
updateCommand?: string
|
||||
}
|
||||
|
||||
interface CheckForUpdatesOptions {
|
||||
verbose?: boolean
|
||||
includeKanban?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version string is a nightly build.
|
||||
*/
|
||||
@@ -91,9 +97,12 @@ function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
* Uses the appropriate tag based on whether the current version is nightly.
|
||||
*/
|
||||
async function getLatestVersion(currentVersion: string): Promise<string | null> {
|
||||
return getLatestPackageVersion("cline", getNpmTag(currentVersion))
|
||||
}
|
||||
|
||||
async function getLatestPackageVersion(packageName: string, tag = "latest"): Promise<string | null> {
|
||||
try {
|
||||
const tag = getNpmTag(currentVersion)
|
||||
const response = await fetch(`https://registry.npmjs.org/cline/${tag}`)
|
||||
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${tag}`)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as { version: string }
|
||||
return data.version || null
|
||||
@@ -102,6 +111,29 @@ async function getLatestVersion(currentVersion: string): Promise<string | null>
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestKanbanVersion(): Promise<string | null> {
|
||||
return getLatestPackageVersion("kanban")
|
||||
}
|
||||
|
||||
function getInstalledKanbanVersion(): string | null {
|
||||
try {
|
||||
const command = process.platform === "win32" ? "kanban.cmd" : "kanban"
|
||||
const result = spawnSync(command, ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim()
|
||||
const versionMatch = output.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)
|
||||
return versionMatch?.[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-update check that runs on CLI startup.
|
||||
* Checks for updates asynchronously (non-blocking), then spawns a detached
|
||||
@@ -157,85 +189,181 @@ async function checkAndUpdate(currentVersion: string, updateCommand: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(updateProcess: ChildProcess): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
updateProcess.once("close", (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
|
||||
updateProcess.once("error", (error) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runClineUpdate(updateCommand: string): Promise<number> {
|
||||
const updateProcess = spawn(updateCommand, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
return waitForProcessExit(updateProcess)
|
||||
}
|
||||
|
||||
type KanbanInstallCommand = NonNullable<ReturnType<typeof resolveKanbanInstallCommand>>
|
||||
|
||||
async function runKanbanUpdate(installCommand: KanbanInstallCommand): Promise<number> {
|
||||
const updateProcess = spawnKanbanInstallProcess(installCommand, {
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
return waitForProcessExit(updateProcess)
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return ""
|
||||
}
|
||||
if (targets.length === 1) {
|
||||
return targets[0]
|
||||
}
|
||||
if (targets.length === 2) {
|
||||
return `${targets[0]} and ${targets[1]}`
|
||||
}
|
||||
return `${targets.slice(0, -1).join(", ")}, and ${targets.at(-1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and install if available (manual command)
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string, options?: { verbose?: boolean }) {
|
||||
printInfo("Checking for updates...")
|
||||
export async function checkForUpdates(currentVersion: string, options: CheckForUpdatesOptions = {}) {
|
||||
const includeKanban = options.includeKanban ?? true
|
||||
|
||||
printInfo("Checking for updates to cline and kanban packages...")
|
||||
|
||||
const { updateCommand, packageManager } = getInstallationInfo(currentVersion)
|
||||
|
||||
try {
|
||||
const latestVersion = await getLatestVersion(currentVersion)
|
||||
if (!latestVersion) {
|
||||
printWarning("Failed to check for updates: could not fetch latest version")
|
||||
exit(1)
|
||||
}
|
||||
const latestClineVersion = await getLatestVersion(currentVersion)
|
||||
const canCheckClineVersion = latestClineVersion !== null
|
||||
|
||||
if (options?.verbose) {
|
||||
printInfo(`Current version: ${currentVersion}`)
|
||||
printInfo(`Latest version: ${latestVersion}`)
|
||||
printInfo(`Package manager: ${packageManager}`)
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
if (latestVersion === currentVersion) {
|
||||
printInfo(`You are already on the latest version (${currentVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Check if current is newer (dev version)
|
||||
if (compareVersions(currentVersion, latestVersion) > 0) {
|
||||
printInfo(`You are already on a newer version ${currentVersion} (latest: ${latestVersion})`)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo(`New version available: ${latestVersion} (current: ${currentVersion})`)
|
||||
|
||||
if (!updateCommand) {
|
||||
printInfo("Unable to determine update command for your installation.")
|
||||
printInfo("Please update manually using your package manager.")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// Ask user to confirm update
|
||||
const userConfirmed = new Promise<boolean>((resolve) => {
|
||||
process.stdout.write("Do you want to update now? (y/N): ")
|
||||
process.stdin.setEncoding("utf-8")
|
||||
process.stdin.once("data", (dataBuff) => {
|
||||
const input = dataBuff.toString().trim().toLowerCase()
|
||||
resolve(input === "y" || input === "yes")
|
||||
})
|
||||
})
|
||||
|
||||
if (!(await userConfirmed)) {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
printInfo(`Installing update via ${packageManager}...`)
|
||||
|
||||
const updateProcess = spawn(updateCommand, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
updateProcess.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
printInfo(`Successfully updated to version ${latestVersion}`)
|
||||
exit(0)
|
||||
} else {
|
||||
printWarning(`Update failed. Please try running: ${updateCommand}`)
|
||||
exit(1)
|
||||
if (canCheckClineVersion) {
|
||||
printInfo(`Latest version: ${latestClineVersion}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateProcess.on("error", (err) => {
|
||||
printWarning(`Failed to run update: ${err.message}`)
|
||||
printInfo(`Please try running manually: ${updateCommand}`)
|
||||
if (!canCheckClineVersion) {
|
||||
printWarning("Failed to check for Cline updates: could not fetch latest version")
|
||||
}
|
||||
|
||||
const clineComparison = latestClineVersion ? compareVersions(currentVersion, latestClineVersion) : null
|
||||
const clineUpdateAvailable = clineComparison !== null && clineComparison < 0
|
||||
const clineIsUpToDate = clineComparison !== null && clineComparison === 0
|
||||
const canUpdateCline = clineUpdateAvailable && Boolean(updateCommand)
|
||||
|
||||
if (clineUpdateAvailable && latestClineVersion) {
|
||||
printInfo(`New version available: ${latestClineVersion} (current: ${currentVersion})`)
|
||||
}
|
||||
|
||||
if (clineUpdateAvailable && !updateCommand) {
|
||||
printInfo("Unable to determine Cline update command for your installation.")
|
||||
printInfo("Please update Cline manually using your package manager.")
|
||||
}
|
||||
|
||||
const kanbanInstallCommand = includeKanban ? resolveKanbanInstallCommand() : null
|
||||
const kanbanInstallerAvailable = kanbanInstallCommand !== null
|
||||
if (includeKanban && !kanbanInstallerAvailable && options.verbose) {
|
||||
printWarning("Unable to determine Kanban update command (npm, pnpm, or bun not found in PATH).")
|
||||
}
|
||||
const latestKanbanVersion = kanbanInstallerAvailable ? await getLatestKanbanVersion() : null
|
||||
const installedKanbanVersion = includeKanban ? getInstalledKanbanVersion() : null
|
||||
const kanbanIsUpToDate =
|
||||
latestKanbanVersion !== null &&
|
||||
installedKanbanVersion !== null &&
|
||||
compareVersions(installedKanbanVersion, latestKanbanVersion) >= 0
|
||||
const shouldInstallKanban =
|
||||
kanbanInstallerAvailable &&
|
||||
latestKanbanVersion !== null &&
|
||||
(installedKanbanVersion === null || compareVersions(installedKanbanVersion, latestKanbanVersion) < 0)
|
||||
|
||||
if (!canCheckClineVersion && !shouldInstallKanban) {
|
||||
exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
if (!canUpdateCline && !shouldInstallKanban) {
|
||||
if (clineIsUpToDate && kanbanIsUpToDate && installedKanbanVersion) {
|
||||
printInfo(`You are already on the latest version cline@${currentVersion} and kanban@${installedKanbanVersion}`)
|
||||
} else if (clineIsUpToDate) {
|
||||
printInfo(`You are already on the latest version cline@${currentVersion}`)
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
let hadFailure = false
|
||||
const installedUpdates: string[] = []
|
||||
|
||||
if (canUpdateCline && updateCommand && latestClineVersion) {
|
||||
printInfo(`Installing cline@${latestClineVersion}...`)
|
||||
try {
|
||||
const clineUpdateCode = await runClineUpdate(updateCommand)
|
||||
if (clineUpdateCode === 0) {
|
||||
installedUpdates.push(`cline@${latestClineVersion}`)
|
||||
} else {
|
||||
printWarning(`Cline update failed. Please try running: ${updateCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Failed to run Cline update: ${message}`)
|
||||
printInfo(`Please try running manually: ${updateCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInstallKanban && kanbanInstallCommand && latestKanbanVersion) {
|
||||
const kanbanTargetVersion = latestKanbanVersion ?? "latest"
|
||||
printInfo(`Installing kanban@${kanbanTargetVersion}...`)
|
||||
try {
|
||||
const kanbanUpdateCode = await runKanbanUpdate(kanbanInstallCommand)
|
||||
if (kanbanUpdateCode === 0) {
|
||||
installedUpdates.push(`kanban@${kanbanTargetVersion}`)
|
||||
} else {
|
||||
printWarning(`Kanban update failed. Please try running: ${kanbanInstallCommand.displayCommand}`)
|
||||
hadFailure = true
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Failed to run Kanban update: ${message}`)
|
||||
if (kanbanInstallCommand) {
|
||||
printInfo(`Please try running manually: ${kanbanInstallCommand.displayCommand}`)
|
||||
}
|
||||
hadFailure = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadFailure) {
|
||||
if (installedUpdates.length > 1) {
|
||||
printSuccess(`Installed updates for ${formatUpdateSummaryTargets(installedUpdates)}`)
|
||||
} else if (installedUpdates.length === 1) {
|
||||
printSuccess(`Installed update for ${installedUpdates[0]}`)
|
||||
} else {
|
||||
printInfo("No updates were installed.")
|
||||
}
|
||||
}
|
||||
|
||||
if (hadFailure) {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
if (canUpdateCline || shouldInstallKanban) {
|
||||
exit(0)
|
||||
}
|
||||
exit(1)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
printWarning(`Error checking for updates: ${message}`)
|
||||
@@ -259,7 +387,7 @@ function parseVersion(version: string): ParsedVersion {
|
||||
return {
|
||||
base: nightlyMatch[1].split(".").map(Number),
|
||||
isNightly: true,
|
||||
timestamp: parseInt(nightlyMatch[2], 10),
|
||||
timestamp: Number.parseInt(nightlyMatch[2], 10),
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: "Authentication"
|
||||
sidebarTitle: "Authentication"
|
||||
description: "How to authenticate with the Cline API using API keys or account tokens."
|
||||
---
|
||||
|
||||
Every request to the Cline API requires authentication via a Bearer token in the `Authorization` header.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
There are two ways to authenticate:
|
||||
|
||||
| Method | Use case | How to get it |
|
||||
|--------|----------|---------------|
|
||||
| **API key** | Direct API calls, scripts, CI/CD | Create at [app.cline.bot](https://app.cline.bot) Settings > API Keys |
|
||||
| **Account auth token** | Cline extension and CLI | Generated automatically when you sign in |
|
||||
|
||||
Both methods use the same header format:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer YOUR_TOKEN
|
||||
```
|
||||
|
||||
## API Keys
|
||||
|
||||
API keys are the recommended authentication method for programmatic access.
|
||||
|
||||
### Creating a Key
|
||||
|
||||
<Steps>
|
||||
<Step title="Sign in">
|
||||
Go to [app.cline.bot](https://app.cline.bot) and sign in.
|
||||
</Step>
|
||||
<Step title="Open API Keys">
|
||||
Navigate to **Settings** > **API Keys**.
|
||||
</Step>
|
||||
<Step title="Create and copy">
|
||||
Create a new key. Copy it immediately as you will not be able to see it again.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Deleting a Key
|
||||
|
||||
You can revoke an API key at any time from the same Settings > API Keys page. Deleted keys stop working immediately.
|
||||
|
||||
You can also manage keys programmatically through the [Enterprise API](/enterprise-solutions/api-reference#api-keys):
|
||||
|
||||
```bash
|
||||
# List your keys
|
||||
curl https://api.cline.bot/api/v1/api-keys \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Delete a key
|
||||
curl -X DELETE https://api.cline.bot/api/v1/api-keys/KEY_ID \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
## Account Auth Tokens
|
||||
|
||||
When you sign in to the Cline extension (VS Code, JetBrains) or CLI, an account auth token is generated and managed automatically. You do not need to handle these tokens manually.
|
||||
|
||||
The Cline CLI uses these tokens when you authenticate via:
|
||||
|
||||
```bash
|
||||
# Interactive sign-in
|
||||
cline auth
|
||||
|
||||
# Or quick setup with an API key
|
||||
cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
See the [CLI Reference](/cline-cli/cli-reference#cline-auth) for all auth options.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**Do:**
|
||||
- Store API keys in environment variables or a secrets manager
|
||||
- Use different keys for development and production
|
||||
- Rotate keys periodically
|
||||
- Delete keys you no longer use
|
||||
|
||||
**Do not:**
|
||||
- Commit keys to version control
|
||||
- Share keys in chat or email
|
||||
- Embed keys in client-side code (browsers, mobile apps)
|
||||
- Log keys in application output
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
```bash
|
||||
# Set the key
|
||||
export CLINE_API_KEY="your_api_key_here"
|
||||
|
||||
# Use it in requests
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $CLINE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "anthropic/claude-sonnet-4-6", "messages": [{"role": "user", "content": "Hello"}]}'
|
||||
```
|
||||
|
||||
### Using a .env File
|
||||
|
||||
```bash
|
||||
# .env (add to .gitignore)
|
||||
CLINE_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
```python
|
||||
import os
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.cline.bot/api/v1",
|
||||
api_key=os.environ["CLINE_API_KEY"],
|
||||
)
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
||||
The Cline API accepts optional headers for tracking and identification:
|
||||
|
||||
| Header | Description |
|
||||
|--------|-------------|
|
||||
| `HTTP-Referer` | Your application's URL. Helps with usage tracking. |
|
||||
| `X-Title` | Your application's name. Appears in usage logs. |
|
||||
| `X-Task-ID` | A unique task identifier. Used internally by the Cline extension. |
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Getting Started" icon="rocket" href="/api/getting-started">
|
||||
Create your first API key and make a request.
|
||||
</Card>
|
||||
<Card title="Enterprise API Keys" icon="building" href="/enterprise-solutions/api-reference#api-keys">
|
||||
Manage API keys programmatically.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
title: "Chat Completions"
|
||||
sidebarTitle: "Chat Completions"
|
||||
description: "Full reference for the POST /chat/completions endpoint including all parameters, streaming, and tool calling."
|
||||
---
|
||||
|
||||
The Chat Completions endpoint generates model responses from a conversation. It follows the [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat/create) format.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://api.cline.bot/api/v1/chat/completions
|
||||
```
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Authorization` | Yes | `Bearer YOUR_API_KEY` |
|
||||
| `Content-Type` | Yes | `application/json` |
|
||||
| `HTTP-Referer` | No | Your application URL (for usage tracking) |
|
||||
| `X-Title` | No | Your application name (for usage logs) |
|
||||
|
||||
## Request Body
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `model` | string | Yes | | Model ID in `provider/model` format. See [Models](/api/models). |
|
||||
| `messages` | array | Yes | | Conversation messages. Each has `role` (`system`, `user`, `assistant`) and `content`. |
|
||||
| `stream` | boolean | No | `true` | Return the response as a stream of Server-Sent Events. |
|
||||
| `tools` | array | No | | Tool/function definitions in OpenAI format. |
|
||||
| `temperature` | number | No | Model default | Sampling temperature (0.0 to 2.0). Lower values are more deterministic. |
|
||||
|
||||
### Message Format
|
||||
|
||||
Each message in the `messages` array has this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Your message here"
|
||||
}
|
||||
```
|
||||
|
||||
**Roles:**
|
||||
|
||||
| Role | Purpose |
|
||||
|------|---------|
|
||||
| `system` | Sets the model's behavior and persona. Place first in the array. |
|
||||
| `user` | The human's input. |
|
||||
| `assistant` | Previous model responses (for multi-turn conversations). |
|
||||
|
||||
### Multi-Turn Conversation
|
||||
|
||||
Include previous messages to maintain context:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful coding assistant."},
|
||||
{"role": "user", "content": "What is a closure in JavaScript?"},
|
||||
{"role": "assistant", "content": "A closure is a function that..."},
|
||||
{"role": "user", "content": "Can you show me an example?"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Response
|
||||
|
||||
When `stream: true` (the default), the response is a series of [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-Sent_Events):
|
||||
|
||||
```
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"role":"assistant"},"index":0}],"model":"anthropic/claude-sonnet-4-6"}
|
||||
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"content":"The capital"},"index":0}],"model":"anthropic/claude-sonnet-4-6"}
|
||||
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"content":" of France"},"index":0}],"model":"anthropic/claude-sonnet-4-6"}
|
||||
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"content":" is Paris."},"index":0,"finish_reason":"stop"}],"model":"anthropic/claude-sonnet-4-6","usage":{"prompt_tokens":14,"completion_tokens":8,"cost":0.000066}}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
Each `data:` line contains a JSON chunk. Key fields:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `id` | Generation ID, consistent across all chunks |
|
||||
| `choices[0].delta.content` | The new text in this chunk |
|
||||
| `choices[0].delta.reasoning` | Reasoning/thinking content (for reasoning models) |
|
||||
| `choices[0].finish_reason` | `stop` when complete, `error` on failure |
|
||||
| `usage` | Token counts and cost (included in the final chunk) |
|
||||
|
||||
### Usage Object
|
||||
|
||||
The final chunk includes token usage and cost:
|
||||
|
||||
```json
|
||||
{
|
||||
"usage": {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 42,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"cost": 0.000315
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `prompt_tokens` | Total input tokens |
|
||||
| `completion_tokens` | Total output tokens |
|
||||
| `prompt_tokens_details.cached_tokens` | Tokens served from cache (reduces cost) |
|
||||
| `cost` | Total cost in USD for this request |
|
||||
|
||||
## Non-Streaming Response
|
||||
|
||||
When `stream: false`, the response is a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen-abc123",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The capital of France is Paris."
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 14,
|
||||
"completion_tokens": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Calling
|
||||
|
||||
You can define tools that the model can call using the OpenAI function calling format:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City and state, e.g. San Francisco, CA"
|
||||
}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When the model decides to call a tool, the response includes a `tool_calls` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\": \"San Francisco, CA\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To continue the conversation after a tool call, include the tool result:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"},
|
||||
{"role": "assistant", "tool_calls": [{"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"San Francisco, CA\"}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call_abc123", "content": "{\"temperature\": 62, \"condition\": \"foggy\"}"},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Reasoning Models
|
||||
|
||||
Some models support extended thinking (reasoning). When using these models, the response may include reasoning content in the streaming delta:
|
||||
|
||||
```json
|
||||
{"choices":[{"delta":{"reasoning":"Let me think about this step by step..."}}]}
|
||||
```
|
||||
|
||||
Reasoning tokens are separate from the main content and appear in the `delta.reasoning` field. Some providers return encrypted reasoning blocks via `delta.reasoning_details` that can be passed back in subsequent requests to preserve the reasoning trace.
|
||||
|
||||
<Note>
|
||||
Not all models support reasoning. See [Models](/api/models) for which models have reasoning capabilities.
|
||||
</Note>
|
||||
|
||||
## Complete Example
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a concise assistant. Answer in one sentence."},
|
||||
{"role": "user", "content": "Explain what an API is."}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Models" icon="brain" href="/api/models">
|
||||
Browse available models and their capabilities.
|
||||
</Card>
|
||||
<Card title="Errors" icon="triangle-exclamation" href="/api/errors">
|
||||
Handle errors and implement retry logic.
|
||||
</Card>
|
||||
<Card title="SDK Examples" icon="code" href="/api/sdk-examples">
|
||||
Use this endpoint from Python, Node.js, and more.
|
||||
</Card>
|
||||
<Card title="Authentication" icon="key" href="/api/authentication">
|
||||
API key management and security practices.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: "Errors"
|
||||
sidebarTitle: "Errors"
|
||||
description: "Error codes, error formats, mid-stream errors, and retry strategies for the Cline API."
|
||||
---
|
||||
|
||||
The Cline API returns errors in a consistent JSON format. Understanding these errors helps you build reliable integrations.
|
||||
|
||||
## Error Format
|
||||
|
||||
All errors follow the OpenAI error format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Invalid API key",
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `code` | number/string | HTTP status code or error identifier |
|
||||
| `message` | string | Human-readable description of the error |
|
||||
| `metadata` | object | Additional context (provider details, request IDs) |
|
||||
|
||||
## Error Codes
|
||||
|
||||
### HTTP Errors
|
||||
|
||||
These are returned as the HTTP response status code and in the error body:
|
||||
|
||||
| Code | Name | Cause | What to do |
|
||||
|------|------|-------|------------|
|
||||
| `400` | Bad Request | Malformed request body, missing required fields | Check your JSON syntax and required parameters |
|
||||
| `401` | Unauthorized | Invalid or missing API key | Verify your API key in the `Authorization` header |
|
||||
| `402` | Payment Required | Insufficient credits | Add credits at [app.cline.bot](https://app.cline.bot) |
|
||||
| `403` | Forbidden | Key does not have access to this resource | Check key permissions |
|
||||
| `404` | Not Found | Invalid endpoint or model ID | Verify the URL and model ID format |
|
||||
| `429` | Too Many Requests | Rate limit exceeded | Wait and retry with exponential backoff |
|
||||
| `500` | Internal Server Error | Server-side issue | Retry after a short delay |
|
||||
| `502` | Bad Gateway | Upstream provider error | Retry after a short delay |
|
||||
| `503` | Service Unavailable | Service temporarily down | Retry after a short delay |
|
||||
|
||||
### Mid-Stream Errors
|
||||
|
||||
When streaming, errors can occur after the response has started. These appear as a chunk with `finish_reason: "error"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "error",
|
||||
"error": {
|
||||
"code": "context_length_exceeded",
|
||||
"message": "The input exceeds the model's maximum context length."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Common mid-stream error codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `context_length_exceeded` | Input tokens exceed the model's context window |
|
||||
| `content_filter` | Content was blocked by a safety filter |
|
||||
| `rate_limit` | Rate limit hit during generation |
|
||||
| `server_error` | Upstream provider failed during generation |
|
||||
|
||||
<Warning>
|
||||
Mid-stream errors do not produce an HTTP error code (the connection was already 200 OK). Always check `finish_reason` in your streaming handler.
|
||||
</Warning>
|
||||
|
||||
## Retry Strategies
|
||||
|
||||
### Exponential Backoff
|
||||
|
||||
For transient errors (429, 500, 502, 503), retry with exponential backoff:
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def call_api_with_retry(payload, max_retries=3):
|
||||
for attempt in range(max_retries):
|
||||
response = requests.post(
|
||||
"https://api.cline.bot/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": "Bearer YOUR_API_KEY",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
if response.status_code in (429, 500, 502, 503):
|
||||
delay = (2 ** attempt) + 1
|
||||
print(f"Retrying in {delay}s (attempt {attempt + 1}/{max_retries})")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
# Non-retryable error
|
||||
response.raise_for_status()
|
||||
|
||||
raise Exception("Max retries exceeded")
|
||||
```
|
||||
|
||||
### When to Retry
|
||||
|
||||
| Error | Retry? | Strategy |
|
||||
|-------|--------|----------|
|
||||
| `401 Unauthorized` | No | Fix your API key |
|
||||
| `402 Payment Required` | No | Add credits |
|
||||
| `429 Too Many Requests` | Yes | Exponential backoff (start at 1s) |
|
||||
| `500 Internal Server Error` | Yes | Retry once after 1s |
|
||||
| `502 Bad Gateway` | Yes | Retry up to 3 times with backoff |
|
||||
| `503 Service Unavailable` | Yes | Retry up to 3 times with backoff |
|
||||
| Mid-stream `error` | Depends | Retry the full request for transient errors |
|
||||
|
||||
### Rate Limits
|
||||
|
||||
If you hit rate limits frequently:
|
||||
|
||||
- Add delays between requests
|
||||
- Reduce the number of concurrent requests
|
||||
- Contact support if you need higher limits
|
||||
|
||||
## Debugging
|
||||
|
||||
When reporting issues, include:
|
||||
|
||||
1. The **error code and message** from the response
|
||||
2. The **model ID** you were using
|
||||
3. The **request ID** (from the `x-request-id` response header, if available)
|
||||
4. Whether the error was **immediate** (HTTP error) or **mid-stream** (finish_reason error)
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Endpoint reference with request and response schemas.
|
||||
</Card>
|
||||
<Card title="Authentication" icon="key" href="/api/authentication">
|
||||
Verify your API key is configured correctly.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
sidebarTitle: "Getting Started"
|
||||
description: "Create an API key and make your first request to the Cline API in under a minute."
|
||||
---
|
||||
|
||||
This guide walks you through creating an API key and making your first Chat Completions request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Cline account at [app.cline.bot](https://app.cline.bot)
|
||||
- `curl` or any HTTP client (Python, Node.js, etc.)
|
||||
|
||||
## Create an API Key
|
||||
|
||||
<Steps>
|
||||
<Step title="Sign in to app.cline.bot">
|
||||
Go to [app.cline.bot](https://app.cline.bot) and sign in with your account.
|
||||
</Step>
|
||||
<Step title="Navigate to API Keys">
|
||||
Open **Settings** and select **API Keys**.
|
||||
</Step>
|
||||
<Step title="Create a new key">
|
||||
Click **Create API Key**. Copy the key immediately. You will not be able to see it again after leaving this page.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Warning>
|
||||
Treat your API key like a password. Do not commit it to version control or share it publicly.
|
||||
</Warning>
|
||||
|
||||
## Make Your First Request
|
||||
|
||||
Replace `YOUR_API_KEY` with the key you just created:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the capital of France?"}
|
||||
],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
## Verify the Response
|
||||
|
||||
You should get a JSON response like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen-abc123",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The capital of France is Paris."
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 14,
|
||||
"completion_tokens": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `choices[0].message.content` field contains the model's reply. The `usage` field shows how many tokens were consumed.
|
||||
|
||||
## Try Streaming
|
||||
|
||||
For real-time output, set `stream: true`. The response arrives as Server-Sent Events:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Write a haiku about programming."}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
Each chunk arrives as a `data:` line. The stream ends with `data: [DONE]`.
|
||||
|
||||
## Try a Free Model
|
||||
|
||||
To test without spending credits, use one of the [free models](/api/models#free-models):
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "minimax/minimax-m2.5",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello! What can you help me with?"}
|
||||
],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| `401 Unauthorized` | Check that your API key is correct and included in the `Authorization` header |
|
||||
| `402 Payment Required` | Your account has insufficient credits. Add credits at [app.cline.bot](https://app.cline.bot) |
|
||||
| Empty response | Make sure `messages` is a non-empty array with at least one user message |
|
||||
| Connection timeout | Verify your network can reach `api.cline.bot`. Check proxy settings if on a corporate network |
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Authentication" icon="key" href="/api/authentication">
|
||||
Learn about API keys, token scoping, and security practices.
|
||||
</Card>
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with all parameters and options.
|
||||
</Card>
|
||||
<Card title="Models" icon="brain" href="/api/models">
|
||||
Browse available models and find the right one for your use case.
|
||||
</Card>
|
||||
<Card title="SDK Examples" icon="code" href="/api/sdk-examples">
|
||||
Use the API from Python, Node.js, or the Cline CLI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: "Models"
|
||||
sidebarTitle: "Models"
|
||||
description: "Available models, pricing tiers, free models, and how model IDs work in the Cline API."
|
||||
---
|
||||
|
||||
The Cline API gives you access to models from multiple providers through a single endpoint. Model IDs follow the `provider/model-name` format, the same convention used by [OpenRouter](https://openrouter.ai).
|
||||
|
||||
## Model ID Format
|
||||
|
||||
Every model is identified by a string in the format:
|
||||
|
||||
```
|
||||
provider/model-name
|
||||
```
|
||||
|
||||
For example:
|
||||
- `anthropic/claude-sonnet-4-6` - Claude Sonnet 4.6 from Anthropic
|
||||
- `openai/gpt-4o` - GPT-4o from OpenAI
|
||||
- `google/gemini-2.5-pro` - Gemini 2.5 Pro from Google
|
||||
|
||||
Pass this string as the `model` parameter in your [Chat Completions](/api/chat-completions) request.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "minimax/minimax-m2.5",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Reasoning Models
|
||||
|
||||
Some models support extended thinking, where the model reasons through a problem before responding. When using these models:
|
||||
|
||||
- Reasoning content appears in `delta.reasoning` during streaming
|
||||
- Some providers return encrypted reasoning blocks in `delta.reasoning_details`
|
||||
- Reasoning tokens are counted separately from output tokens
|
||||
|
||||
Models with reasoning support include most Claude, Gemini 2.5, and Grok 3 models. Check the model's `supportsReasoning` capability in the model catalog.
|
||||
|
||||
## Choosing a Model
|
||||
|
||||
| If you need... | Consider |
|
||||
|----------------|----------|
|
||||
| Best coding performance | `anthropic/claude-sonnet-4-6` |
|
||||
| Long document analysis | `google/gemini-2.5-pro` (1M context) |
|
||||
| Fast, cheap responses | `deepseek/deepseek-chat` |
|
||||
| Free experimentation | `minimax/minimax-m2.5` |
|
||||
| Multi-modal (text + images) | `openai/gpt-4o` or `anthropic/claude-sonnet-4-6` |
|
||||
| Complex reasoning | Any model with reasoning support |
|
||||
|
||||
For a deeper comparison of model capabilities and pricing, see the [Model Selection Guide](/core-features/model-selection-guide).
|
||||
|
||||
## Image Support
|
||||
|
||||
Models that support images accept base64-encoded image content in the `messages` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Not all models support images. Check the model's `supportsImages` capability before sending image content.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Use these models in your API requests.
|
||||
</Card>
|
||||
<Card title="Model Selection Guide" icon="scale-balanced" href="/core-features/model-selection-guide">
|
||||
In-depth comparison for choosing the right model.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "Cline API"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Programmatic access to AI models through an OpenAI-compatible Chat Completions API."
|
||||
---
|
||||
|
||||
Welcome to the Cline API documentation. Use the same models that power the Cline extension and CLI from any language, framework, or tool that speaks the OpenAI format.
|
||||
|
||||
## What is the Cline API?
|
||||
|
||||
The Cline API is an OpenAI-compatible Chat Completions endpoint. You authenticate once with a Cline API key and get access to models from Anthropic, OpenAI, Google, and more through a single base URL. No need to manage separate keys for each provider.
|
||||
|
||||
```
|
||||
Your App → Cline API (api.cline.bot) → Anthropic / OpenAI / Google / etc.
|
||||
```
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Getting Started" icon="rocket" href="/api/getting-started">
|
||||
Create an API key and make your first request in under a minute.
|
||||
</Card>
|
||||
<Card title="Authentication" icon="key" href="/api/authentication">
|
||||
API keys, account tokens, key rotation, and security best practices.
|
||||
</Card>
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with request schemas, streaming, and tool calling.
|
||||
</Card>
|
||||
<Card title="Code Examples" icon="code" href="/api/sdk-examples">
|
||||
Ready-to-copy examples for Python, Node.js, curl, and the Cline CLI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Explore the Reference
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Models" icon="brain" href="/api/models">
|
||||
Browse available models, free tier options, reasoning support, and selection guidance.
|
||||
</Card>
|
||||
<Card title="Errors" icon="triangle-exclamation" href="/api/errors">
|
||||
Error codes, mid-stream errors, retry strategies, and debugging tips.
|
||||
</Card>
|
||||
<Card title="Enterprise API" icon="building" href="/enterprise-solutions/api-reference">
|
||||
Admin endpoints for managing users, organizations, billing, and API keys.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
Get your API key at [app.cline.bot](https://app.cline.bot) (Settings > API Keys), then follow the [Getting Started](/api/getting-started) guide.
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
title: "Cline API Reference"
|
||||
sidebarTitle: "API Reference"
|
||||
description: "Reference for the Cline Chat Completions API, an OpenAI-compatible endpoint for programmatic access."
|
||||
---
|
||||
|
||||
The Cline API provides an OpenAI-compatible Chat Completions endpoint. You can use it from the Cline extension, the CLI, or any HTTP client that speaks the OpenAI format.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.cline.bot/api/v1
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests require a Bearer token in the `Authorization` header. You can use either:
|
||||
|
||||
- **API key** created at [app.cline.bot](https://app.cline.bot) (Settings > API Keys)
|
||||
- **Account auth token** (used automatically by the Cline extension and CLI when you sign in)
|
||||
|
||||
```bash
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
### Getting an API Key
|
||||
|
||||
<Steps>
|
||||
<Step title="Go to app.cline.bot">
|
||||
Open [app.cline.bot](https://app.cline.bot) and sign in.
|
||||
</Step>
|
||||
<Step title="Open Settings > API Keys">
|
||||
Navigate to **Settings**, then **API Keys**.
|
||||
</Step>
|
||||
<Step title="Create and copy your key">
|
||||
Create a new key and copy it. Store it securely. You will not be able to see it again.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Chat Completions
|
||||
|
||||
Create a chat completion with streaming support. This endpoint follows the [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat/create) format.
|
||||
|
||||
### Request
|
||||
|
||||
```
|
||||
POST /chat/completions
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Authorization` | Yes | `Bearer YOUR_API_KEY` |
|
||||
| `Content-Type` | Yes | `application/json` |
|
||||
| `HTTP-Referer` | No | Your application URL |
|
||||
| `X-Title` | No | Your application name |
|
||||
|
||||
**Body parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `model` | string | Yes | Model ID in `provider/model` format (e.g., `anthropic/claude-sonnet-4-6`) |
|
||||
| `messages` | array | Yes | Array of message objects with `role` and `content` |
|
||||
| `stream` | boolean | No | Enable SSE streaming (default: `true`) |
|
||||
| `tools` | array | No | Tool definitions in OpenAI function calling format |
|
||||
| `temperature` | number | No | Sampling temperature |
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Explain what a context window is in 2 sentences."}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Response (Streaming)
|
||||
|
||||
When `stream: true`, the response is a series of [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-Sent_Events). Each event contains a JSON chunk:
|
||||
|
||||
```json
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"content":"A context"},"index":0}],"model":"anthropic/claude-sonnet-4-6"}
|
||||
|
||||
data: {"id":"gen-abc123","choices":[{"delta":{"content":" window is"},"index":0}],"model":"anthropic/claude-sonnet-4-6"}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
The final chunk includes a `usage` object with token counts and cost:
|
||||
|
||||
```json
|
||||
{
|
||||
"usage": {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 42,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"cost": 0.000315
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Non-Streaming)
|
||||
|
||||
When `stream: false`, the response is a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen-abc123",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "A context window is the maximum amount of text..."
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
Model IDs use the `provider/model-name` format, the same format used by [OpenRouter](https://openrouter.ai). Some examples:
|
||||
|
||||
| Model ID | Description |
|
||||
|----------|-------------|
|
||||
| `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 |
|
||||
| `anthropic/claude-sonnet-4-5` | Claude Sonnet 4.5 |
|
||||
| `google/gemini-2.5-pro` | Gemini 2.5 Pro |
|
||||
| `openai/gpt-4o` | GPT-4o |
|
||||
|
||||
### Free Models
|
||||
|
||||
The following models are available at no cost:
|
||||
|
||||
| Model ID | Provider |
|
||||
|----------|----------|
|
||||
| `minimax/minimax-m2.5` | MiniMax |
|
||||
| `kwaipilot/kat-coder-pro` | Kwaipilot |
|
||||
| `z-ai/glm-5` | Z-AI |
|
||||
|
||||
<Note>
|
||||
Model availability and pricing may change. Check [app.cline.bot](https://app.cline.bot) for the latest list.
|
||||
</Note>
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors follow the OpenAI error format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 401,
|
||||
"message": "Invalid API key",
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `401` | Invalid or missing API key |
|
||||
| `402` | Insufficient credits |
|
||||
| `429` | Rate limit exceeded |
|
||||
| `500` | Server error |
|
||||
| `error` (finish_reason) | Mid-stream error from the upstream model provider |
|
||||
|
||||
## Using with Cline
|
||||
|
||||
The easiest way to use the Cline API is through the Cline extension or CLI, which handle authentication and streaming for you.
|
||||
|
||||
### VS Code / JetBrains
|
||||
|
||||
Select **Cline** as your provider in the model picker dropdown. Sign in with your Cline account and your API key is managed automatically.
|
||||
|
||||
### Cline CLI
|
||||
|
||||
Configure the CLI with your API key in one command:
|
||||
|
||||
```bash
|
||||
cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
Then run tasks normally:
|
||||
|
||||
```bash
|
||||
cline "Write a one-line hello world in Python."
|
||||
```
|
||||
|
||||
See the [CLI Reference](/cline-cli/cli-reference) for all available commands and options.
|
||||
|
||||
## Using with Other Tools
|
||||
|
||||
Because the Cline API is OpenAI-compatible, you can use it with any library or tool that supports custom OpenAI endpoints.
|
||||
|
||||
### Python (OpenAI SDK)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.cline.bot/api/v1",
|
||||
api_key="YOUR_API_KEY",
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### Node.js (OpenAI SDK)
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai"
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/api/v1",
|
||||
apiKey: "YOUR_API_KEY",
|
||||
})
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
})
|
||||
console.log(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="CLI Reference" icon="terminal" href="/cline-cli/cli-reference">
|
||||
Full command reference for the Cline CLI, including auth setup.
|
||||
</Card>
|
||||
<Card title="Enterprise API" icon="building" href="/enterprise-solutions/api-reference">
|
||||
Admin endpoints for user management, organizations, billing, and API keys.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
title: "Code Examples"
|
||||
sidebarTitle: "Code Examples"
|
||||
description: "Use the Cline API from Python, Node.js, curl, the Cline CLI, and the VS Code extension."
|
||||
---
|
||||
|
||||
The Cline API is OpenAI-compatible, so any library or tool that works with OpenAI also works with the Cline API. Just change the base URL and API key.
|
||||
|
||||
## curl
|
||||
|
||||
### Non-Streaming
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $CLINE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [{"role": "user", "content": "What is 2+2?"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.cline.bot/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $CLINE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [{"role": "user", "content": "Write a short poem about code."}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
The [OpenAI Python SDK](https://github.com/openai/openai-python) works with the Cline API by setting `base_url`:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.cline.bot/api/v1",
|
||||
api_key="YOUR_API_KEY",
|
||||
)
|
||||
|
||||
# Non-streaming
|
||||
response = client.chat.completions.create(
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Explain recursion in one sentence."}],
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### Streaming in Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.cline.bot/api/v1",
|
||||
api_key="YOUR_API_KEY",
|
||||
)
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "Write a function to reverse a string in Python."}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
```
|
||||
|
||||
### Tool Calling in Python
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
import json
|
||||
|
||||
client = OpenAI(
|
||||
base_url="https://api.cline.bot/api/v1",
|
||||
api_key="YOUR_API_KEY",
|
||||
)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
messages=[{"role": "user", "content": "What's the weather in Tokyo?"}],
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
# Check if the model wants to call a tool
|
||||
choice = response.choices[0]
|
||||
if choice.message.tool_calls:
|
||||
tool_call = choice.message.tool_calls[0]
|
||||
print(f"Tool: {tool_call.function.name}")
|
||||
print(f"Args: {tool_call.function.arguments}")
|
||||
```
|
||||
|
||||
### Using requests
|
||||
|
||||
If you prefer not to use the OpenAI SDK:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
"https://api.cline.bot/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": "Bearer YOUR_API_KEY",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"messages": [{"role": "user", "content": "Hello!"}],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
print(data["choices"][0]["message"]["content"])
|
||||
```
|
||||
|
||||
## Node.js / TypeScript
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
The [OpenAI Node.js SDK](https://github.com/openai/openai-node) works with the Cline API by setting `baseURL`:
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai"
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/api/v1",
|
||||
apiKey: "YOUR_API_KEY",
|
||||
})
|
||||
|
||||
// Non-streaming
|
||||
const response = await client.chat.completions.create({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: "Explain async/await in one sentence." }],
|
||||
})
|
||||
console.log(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
### Streaming in Node.js
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai"
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/api/v1",
|
||||
apiKey: "YOUR_API_KEY",
|
||||
})
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: "Write a haiku about TypeScript." }],
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content
|
||||
if (content) {
|
||||
process.stdout.write(content)
|
||||
}
|
||||
}
|
||||
console.log()
|
||||
```
|
||||
|
||||
### Using fetch
|
||||
|
||||
```typescript
|
||||
const response = await fetch("https://api.cline.bot/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer YOUR_API_KEY",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "user", content: "Hello!" }],
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log(data.choices[0].message.content)
|
||||
```
|
||||
|
||||
## Cline CLI
|
||||
|
||||
The [Cline CLI](/cline-cli/cli-reference) is the fastest way to use the Cline API from your terminal. It handles authentication, streaming, and tool execution for you.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g @anthropic-ai/cline
|
||||
|
||||
# Authenticate with a Cline API key
|
||||
cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
### Run Tasks
|
||||
|
||||
```bash
|
||||
# Simple prompt
|
||||
cline "Explain what a REST API is."
|
||||
|
||||
# Pipe input
|
||||
cat README.md | cline "Summarize this document."
|
||||
|
||||
# Use a specific model
|
||||
cline -m google/gemini-2.5-pro "Analyze this codebase."
|
||||
|
||||
# YOLO mode for automation
|
||||
cline -y "Run tests and fix failures."
|
||||
```
|
||||
|
||||
See the [CLI Reference](/cline-cli/cli-reference) for all commands and options.
|
||||
|
||||
## VS Code / JetBrains
|
||||
|
||||
The Cline extension handles the API integration for you:
|
||||
|
||||
1. Open the Cline panel in your editor
|
||||
2. Select **Cline** as the provider in the model picker
|
||||
3. Sign in with your Cline account
|
||||
4. Start chatting or give Cline a task
|
||||
|
||||
Your API key is managed automatically. No manual configuration needed.
|
||||
|
||||
For setup instructions, see [Installing Cline](/getting-started/installing-cline) and [Authorizing with Cline](/getting-started/authorizing-with-cline).
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Chat Completions" icon="message" href="/api/chat-completions">
|
||||
Full endpoint reference with all parameters.
|
||||
</Card>
|
||||
<Card title="Authentication" icon="key" href="/api/authentication">
|
||||
API key management and security practices.
|
||||
</Card>
|
||||
<Card title="Models" icon="brain" href="/api/models">
|
||||
Browse available models.
|
||||
</Card>
|
||||
<Card title="CLI Reference" icon="terminal" href="/cline-cli/cli-reference">
|
||||
Complete Cline CLI command reference.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 MiB After Width: | Height: | Size: 8.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 6.1 MiB |
@@ -63,6 +63,9 @@ cline
|
||||
|
||||
# Start a task directly
|
||||
cline "your prompt here"
|
||||
|
||||
# Resume the latest task for the current directory
|
||||
cline --continue
|
||||
```
|
||||
|
||||
**Options:**
|
||||
@@ -77,6 +80,7 @@ cline "your prompt here"
|
||||
| `--thinking` | Enable extended thinking with a 1024 token budget. |
|
||||
| `--json` | Output messages as JSON (one object per line). Forces plain text mode. |
|
||||
| `--timeout <seconds>` | Maximum execution time before the task is stopped. |
|
||||
| `--continue` | Resume the most recent task from the current working directory. |
|
||||
|
||||
**Mode Behavior:**
|
||||
|
||||
|
||||
@@ -180,13 +180,23 @@ Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, gi
|
||||
|
||||
### Setting Up MCP Servers
|
||||
|
||||
To configure MCP servers for the CLI, create or edit the settings file at:
|
||||
You can add MCP servers from the CLI:
|
||||
|
||||
```bash
|
||||
# STDIO server
|
||||
cline mcp add kanban -- kanban mcp
|
||||
|
||||
# Remote HTTP server
|
||||
cline mcp add linear https://mcp.linear.app/mcp --type http
|
||||
```
|
||||
|
||||
These commands update:
|
||||
|
||||
```
|
||||
~/.cline/data/settings/cline_mcp_settings.json
|
||||
```
|
||||
|
||||
The file uses the same JSON format as the VS Code extension:
|
||||
You can still edit this file directly. It uses the same JSON format as the VS Code extension:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -207,7 +217,7 @@ The file uses the same JSON format as the VS Code extension:
|
||||
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
|
||||
|
||||
<Note>
|
||||
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
|
||||
The CLI does not yet have a `/mcp` slash command for interactive management inside the terminal UI. Use `cline mcp add` or edit `cline_mcp_settings.json` directly.
|
||||
</Note>
|
||||
|
||||
### Custom Config Directory
|
||||
|
||||
+265
-139
@@ -1,3 +1,9 @@
|
||||
---
|
||||
title: "Cline SDK"
|
||||
sidebarTitle: "SDK (Programmatic Use)"
|
||||
description: "Embed Cline as a programmable coding agent in your Node.js applications using an ACP-compatible TypeScript API."
|
||||
---
|
||||
|
||||
# Cline SDK
|
||||
|
||||
The Cline SDK lets you embed Cline as a programmable coding agent in your Node.js applications. It exposes the same capabilities as the Cline CLI and VS Code extension — file editing, command execution, browser use, MCP servers — through a TypeScript API that conforms to the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/protocol/schema).
|
||||
@@ -19,50 +25,72 @@ Requires Node.js 20+.
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { ClineAgent } from "cline"
|
||||
import { ClineAgent } from "cline";
|
||||
|
||||
const agent = new ClineAgent({ version: "1.0.0" })
|
||||
const CLINE_DIR = "/Users/username/.cline";
|
||||
const agent = new ClineAgent({ clineDir: CLINE_DIR });
|
||||
|
||||
// 1. Initialize — negotiates capabilities
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
})
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
// these are the capabilities that the client (you) supports
|
||||
// The cline agent may or may not use them, but it needs to know about them to make informed decisions about what tools to use.
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Authenticate (if using Cline-hosted models)
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
const { agentInfo, authMethods } = initializeResponse;
|
||||
console.log("Agent info:", agentInfo); // contains things like agent name and version
|
||||
console.log("Auth methods:", authMethods); // contains a list of supported authentication methods. More auth methods coming soon
|
||||
|
||||
// 3. Create a session
|
||||
// 2. Authenticate if needed
|
||||
// If you skip this step, ClineAgent will look in CLINE_DIR for any existing credentials and authenticate with those
|
||||
await agent.authenticate({ methodId: "cline-oauth" });
|
||||
|
||||
// 3. Create a session.
|
||||
// A session represents a conversation or task with the agent. You can have multiple sessions for different tasks or conversations.
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
})
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [], // mcpServers field not supported yet, but exposed here to maintain conformance with acp protocol
|
||||
});
|
||||
|
||||
// 4. Subscribe to streaming output
|
||||
const emitter = agent.emitterForSession(sessionId)
|
||||
// 4. Agent updates are sent via events. You can subscribe to these events to get real-time updates on the agent's progress, tool calls, and more.
|
||||
const emitter = agent.emitterForSession(sessionId);
|
||||
|
||||
emitter.on("agent_message_chunk", (payload) => {
|
||||
process.stdout.write(payload.content.text)
|
||||
})
|
||||
|
||||
process.stdout.write(
|
||||
payload.content.type === "text"
|
||||
? payload.content.text
|
||||
: `[${payload.content.type}]`,
|
||||
);
|
||||
});
|
||||
emitter.on("agent_thought_chunk", (payload) => {
|
||||
process.stdout.write(
|
||||
payload.content.type === "text"
|
||||
? payload.content.text
|
||||
: `[${payload.content.type}]`,
|
||||
);
|
||||
});
|
||||
emitter.on("tool_call", (payload) => {
|
||||
console.log(`[tool] ${payload.title}`)
|
||||
})
|
||||
|
||||
console.log(`[tool] ${payload.title}`);
|
||||
});
|
||||
emitter.on("error", (err) => {
|
||||
console.error("[session error]", err)
|
||||
})
|
||||
console.error("[session error]", err);
|
||||
});
|
||||
|
||||
// 5. Send a prompt and wait for completion
|
||||
const { stopReason } = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "Create a hello world Express server" }],
|
||||
})
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "Create a hello world Express server" }],
|
||||
});
|
||||
|
||||
console.log("Done:", stopReason)
|
||||
console.log("Done:", stopReason);
|
||||
|
||||
// 6. Clean up
|
||||
await agent.shutdown()
|
||||
await agent.shutdown();
|
||||
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
@@ -78,7 +106,7 @@ initialize() → authenticate() → newSession() → prompt() ⇄ events → shu
|
||||
| Step | Method | Purpose |
|
||||
|------|--------|---------|
|
||||
| Init | `initialize()` | Exchange protocol version and capabilities |
|
||||
| Auth | `authenticate()` | OAuth flow for Cline or OpenAI Codex accounts |
|
||||
| Auth | `authenticate()` | OAuth flow for Cline or OpenAI Codex accounts. Optional step if cline config directory already has credentials |
|
||||
| Session | `newSession()` | Create an isolated conversation context |
|
||||
| Prompt | `prompt()` | Send user messages; blocks until the turn ends |
|
||||
| Cancel | `cancel()` | Abort an in-progress prompt turn |
|
||||
@@ -88,14 +116,12 @@ initialize() → authenticate() → newSession() → prompt() ⇄ events → shu
|
||||
|
||||
### Sessions
|
||||
|
||||
A session is an independent conversation with its own task history, working directory, and MCP server connections. You can run multiple sessions concurrently.
|
||||
A session is an independent conversation with its own task history and working directory. You can run multiple sessions concurrently.
|
||||
|
||||
```typescript
|
||||
const { sessionId, modes, models } = await agent.newSession({
|
||||
cwd: "/path/to/project",
|
||||
mcpServers: [
|
||||
{ name: "my-server", command: "npx", args: ["-y", "my-mcp-server"] },
|
||||
],
|
||||
mcpServers: [], // mcpServers field not supported yet, but exposed here to maintain conformance with acp protocol
|
||||
})
|
||||
```
|
||||
|
||||
@@ -155,14 +181,14 @@ await agent.prompt({
|
||||
|
||||
#### Stop Reasons
|
||||
|
||||
`prompt()` resolves with a `stopReason`:
|
||||
`prompt()` resolves with a `stopReason`. The ACP `StopReason` type defines the full set of possible values:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `"end_turn"` | Agent finished normally (completed task or waiting for user input) |
|
||||
| `"cancelled"` | You called `cancel()` during the turn |
|
||||
| `"error"` | An unrecoverable error occurred |
|
||||
| `"max_tokens"` | Context window exhausted |
|
||||
| `"error"` | An error occurred |
|
||||
|
||||
> **Note:** Cline currently returns `"end_turn"` or `"error"`. Other `StopReason` values like `"max_tokens"` or `"cancelled"` are part of the ACP type but may not be produced by the current implementation.
|
||||
|
||||
### Streaming Events
|
||||
|
||||
@@ -220,7 +246,7 @@ The emitter supports `on`, `once`, `off`, and `removeAllListeners`.
|
||||
When the agent wants to execute a tool (edit a file, run a command, etc.), it requests permission. You **must** set a permission handler or all tool calls will be auto-rejected.
|
||||
|
||||
```typescript
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
// request.toolCall — details about what the agent wants to do
|
||||
// request.options — available choices (allow_once, reject_once, etc.)
|
||||
|
||||
@@ -228,11 +254,11 @@ agent.setPermissionHandler((request, resolve) => {
|
||||
console.log("Options:", request.options.map(o => `${o.optionId} (${o.kind})`))
|
||||
|
||||
// Auto-approve everything:
|
||||
const allowOption = request.options.find(o => o.kind === "allow_once")
|
||||
const allowOption = request.options.find(o => o.kind.includes("allow"))
|
||||
if (allowOption) {
|
||||
resolve({ outcome: { outcome: "selected", optionId: allowOption.optionId } })
|
||||
return { outcome: { outcome: "selected", optionId: allowOption.optionId } }
|
||||
} else {
|
||||
resolve({ outcome: { outcome: "rejected" } })
|
||||
return { outcome: { outcome: "rejected" } }
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -244,9 +270,8 @@ Each permission request includes an array of `PermissionOption` objects:
|
||||
| `kind` | Meaning |
|
||||
|--------|---------|
|
||||
| `allow_once` | Approve this single operation |
|
||||
| `allow_always` | Approve and remember for future operations |
|
||||
| `allow_always` | Approve and remember for future operations (sent for commands, tools, MCP servers) |
|
||||
| `reject_once` | Deny this single operation |
|
||||
| `reject_always` | Deny and remember for future operations |
|
||||
|
||||
**Important:** If no permission handler is set, all tool calls are rejected for safety.
|
||||
|
||||
@@ -265,7 +290,7 @@ await agent.setSessionMode({ sessionId, modeId: "plan" })
|
||||
await agent.setSessionMode({ sessionId, modeId: "act" })
|
||||
```
|
||||
|
||||
The current mode is returned in `newSession()` and emitted via `current_mode_update` events.
|
||||
The current mode is returned in `newSession()`
|
||||
|
||||
### Model Selection
|
||||
|
||||
@@ -278,9 +303,9 @@ await agent.unstable_setSessionModel({
|
||||
})
|
||||
```
|
||||
|
||||
This sets the model for both plan and act modes. Available providers include `anthropic`, `openai-native`, `gemini`, `bedrock`, `deepseek`, `mistral`, `groq`, `xai`, and others.
|
||||
This sets the model for both plan and act modes. Available providers include `anthropic`, `openai-native`, `gemini`, `bedrock`, `deepseek`, `mistral`, `groq`, `xai`, and others. Model Ids can be found in the NewSessionResponse object after calling `agent.newSession(..)`
|
||||
|
||||
> **Note:** This API is experimental and may change.
|
||||
> **Note:** This API is experimental and may change.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -296,7 +321,29 @@ await agent.authenticate({ methodId: "openai-codex-oauth" })
|
||||
|
||||
Both methods open a browser window for the OAuth flow and block until authentication completes (5-minute timeout for Cline OAuth).
|
||||
|
||||
For BYO (bring-your-own) API key providers, configure the key through the state manager before creating a session. The `authenticate()` call is not needed for BYO providers.
|
||||
For BYO (bring-your-own) API key providers, you can pre-configure credentials using the Cline CLI before using the SDK:
|
||||
|
||||
```bash
|
||||
# Configure an Anthropic API key (default directory: ~/.cline/data/)
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Configure an OpenRouter API key
|
||||
cline auth -p openrouter -k "sk-or-..." -m openrouter/anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
This writes credentials to `~/.cline/data/`. Once configured, the SDK will use these credentials automatically — no `authenticate()` call needed.
|
||||
|
||||
**Using a custom directory:** If you specify a custom `clineDir` when creating `ClineAgent`, you must use the same path with `--config` when running `cline auth`:
|
||||
|
||||
```typescript
|
||||
// SDK code using custom directory
|
||||
const agent = new ClineAgent({ clineDir: "/custom/path" })
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI auth command must use the same path
|
||||
cline auth -p anthropic -k "sk-ant-..." -m anthropic/claude-sonnet-4-20250514 --config /custom/path
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -306,8 +353,6 @@ Cancel an in-progress prompt turn:
|
||||
await agent.cancel({ sessionId })
|
||||
```
|
||||
|
||||
The pending `prompt()` call will resolve with `{ stopReason: "cancelled" }`.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
@@ -318,12 +363,12 @@ new ClineAgent(options: ClineAgentOptions)
|
||||
|
||||
```typescript
|
||||
interface ClineAgentOptions {
|
||||
/** Version string for your application (required) */
|
||||
version: string
|
||||
/** Enable debug logging (default: false) */
|
||||
debug?: boolean
|
||||
/** Custom Cline config directory (default: ~/.cline) */
|
||||
clineDir?: string
|
||||
/** Additional runtime hooks directory */
|
||||
hooksDir?: string
|
||||
}
|
||||
```
|
||||
|
||||
@@ -331,7 +376,6 @@ The `clineDir` option lets you isolate configuration and task history per-applic
|
||||
|
||||
```typescript
|
||||
const agent = new ClineAgent({
|
||||
version: "1.0.0",
|
||||
clineDir: "/tmp/my-app-cline",
|
||||
})
|
||||
```
|
||||
@@ -350,13 +394,13 @@ const response = await agent.initialize({
|
||||
|
||||
// Response includes:
|
||||
{
|
||||
protocolVersion: "0.9.0",
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: true, sse: false }
|
||||
},
|
||||
agentInfo: { name: "cline", version: "2.2.3" },
|
||||
agentInfo: { name: "cline", version: "<installed_version>" },
|
||||
authMethods: [
|
||||
{ id: "cline-oauth", name: "Sign in with Cline", description: "..." },
|
||||
{ id: "openai-codex-oauth", name: "Sign in with ChatGPT", description: "..." }
|
||||
@@ -364,6 +408,24 @@ const response = await agent.initialize({
|
||||
}
|
||||
```
|
||||
|
||||
#### Client Capabilities
|
||||
|
||||
The `clientCapabilities` object in `initialize()` declares what your environment supports. It is part of the ACP protocol handshake.
|
||||
|
||||
| Capability | Type | Description |
|
||||
|------------|------|-------------|
|
||||
| `fs.readTextFile` | `boolean` | Client supports file read requests |
|
||||
| `fs.writeTextFile` | `boolean` | Client supports file write requests |
|
||||
| `terminal` | `boolean` | Client supports terminal command execution |
|
||||
|
||||
**When using `ClineAgent` directly (SDK use)**, the agent always uses standalone providers for file operations and terminal commands — it reads/writes files and runs shell commands on the local machine regardless of what you pass here. Simply pass `{}`:
|
||||
|
||||
```typescript
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} })
|
||||
```
|
||||
|
||||
These capabilities only affect behavior when `ClineAgent` is used through the `AcpAgent` stdio wrapper (e.g., IDE integrations), where an ACP connection delegates operations back to the client.
|
||||
|
||||
#### `newSession(params): Promise<NewSessionResponse>`
|
||||
|
||||
Create a new conversation session.
|
||||
@@ -393,8 +455,8 @@ const session = await agent.newSession({
|
||||
currentModeId: "act"
|
||||
},
|
||||
models: {
|
||||
currentModelId: "anthropic/claude-sonnet-4-5-20241022",
|
||||
availableModels: [{ modelId: "anthropic/claude-3-5-sonnet-20241022", name: "..." }]
|
||||
currentModelId: "anthropic/claude-sonnet-4-20250514",
|
||||
availableModels: [{ modelId: "anthropic/claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" } /* ... */]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -434,7 +496,7 @@ await agent.setSessionMode({ sessionId, modeId: "plan" })
|
||||
|
||||
#### `unstable_setSessionModel(params): Promise<SetSessionModelResponse>`
|
||||
|
||||
Change the model for the session. Model ID format: `"provider/modelId"`.
|
||||
Change the model for the session. Model ID format depends on the inference provider. See NewSessionResponse object to get modelIds.
|
||||
|
||||
```typescript
|
||||
await agent.unstable_setSessionModel({
|
||||
@@ -451,6 +513,14 @@ Authenticate with a provider. Opens a browser window for OAuth flow.
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
```
|
||||
|
||||
Current methodIds we support:
|
||||
|
||||
| methodId | Description |
|
||||
| -------------------- | ----------------------------- |
|
||||
| `cline-oauth` | use cline inference provider |
|
||||
| `openai-codex-oauth` | use your chatgpt subscription |
|
||||
| more coming soon!... | |
|
||||
|
||||
#### `shutdown(): Promise<void>`
|
||||
|
||||
Clean up all resources. Call this when done.
|
||||
@@ -461,11 +531,18 @@ await agent.shutdown()
|
||||
|
||||
#### `setPermissionHandler(handler)`
|
||||
|
||||
Set a callback to handle tool permission requests.
|
||||
Set a callback to handle tool permission requests. The handler receives a `RequestPermissionRequest` and must return a `Promise<RequestPermissionResponse>`.
|
||||
|
||||
```typescript
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
resolve({ outcome: { outcome: "selected", optionId: "allow_once" } })
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
// request.toolCall — details about what the agent wants to do
|
||||
// request.options — available choices (allow_once, reject_once, etc.)
|
||||
const allow = request.options.find(o => o.kind === "allow_once")
|
||||
return {
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "cancelled" }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
@@ -487,119 +564,157 @@ for (const [sessionId, session] of agent.sessions) {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SDK methods throw standard JavaScript errors. Key error scenarios:
|
||||
|
||||
| Method | Error | Cause |
|
||||
|--------|-------|-------|
|
||||
| `newSession()` | `RequestError` (auth required) | No credentials configured — call `authenticate()` or pre-configure via CLI |
|
||||
| `prompt()` | `Error("Session not found")` | Invalid `sessionId` |
|
||||
| `prompt()` | `Error("already processing")` | Called `prompt()` while a previous prompt is still running on the same session |
|
||||
| `unstable_setSessionModel()` | `Error("Invalid modelId format")` | Model ID must be `"provider/modelId"` format (e.g., `"anthropic/claude-sonnet-4-20250514"`) |
|
||||
| `authenticate()` | `Error("Unknown authentication method")` | Invalid `methodId` — use `"cline-oauth"` or `"openai-codex-oauth"` |
|
||||
| `authenticate()` | `Error("Authentication timed out")` | OAuth flow not completed within 5 minutes |
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
} catch (error) {
|
||||
if (error.message?.includes("auth")) {
|
||||
// Need to authenticate first
|
||||
await agent.authenticate({ methodId: "cline-oauth" })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Session-level errors during `prompt()` execution are emitted on the session emitter rather than thrown:
|
||||
|
||||
```typescript
|
||||
emitter.on("error", (err) => {
|
||||
console.error("Session error:", err.message)
|
||||
})
|
||||
```
|
||||
|
||||
## Full Example: Auto-Approve Agent
|
||||
|
||||
```typescript
|
||||
import { ClineAgent } from "cline"
|
||||
import { ClineAgent } from "cline";
|
||||
|
||||
async function runTask(task: string, cwd: string) {
|
||||
const agent = new ClineAgent({ version: "1.0.0" })
|
||||
async function runTask(taskPrompt: string, cwd: string) {
|
||||
const agent = new ClineAgent({ clineDir: "/path/to/.cline" });
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
})
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
});
|
||||
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] })
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
|
||||
// Auto-approve all tool calls
|
||||
agent.setPermissionHandler((request, resolve) => {
|
||||
const allow = request.options.find(o => o.kind === "allow_once")
|
||||
resolve({
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "rejected" },
|
||||
})
|
||||
})
|
||||
// Auto-approve all tool calls
|
||||
agent.setPermissionHandler(async (request) => {
|
||||
const allow = request.options.find((o) => o.kind === "allow_once");
|
||||
return {
|
||||
outcome: allow
|
||||
? { outcome: "selected", optionId: allow.optionId }
|
||||
: { outcome: "cancelled" },
|
||||
};
|
||||
});
|
||||
|
||||
// Collect output
|
||||
const output: string[] = []
|
||||
const emitter = agent.emitterForSession(sessionId)
|
||||
// Collect output
|
||||
const output: string[] = [];
|
||||
const emitter = agent.emitterForSession(sessionId);
|
||||
|
||||
emitter.on("agent_message_chunk", (p) => {
|
||||
if (p.content.type === "text") output.push(p.content.text)
|
||||
})
|
||||
emitter.on("agent_message_chunk", (p) => {
|
||||
if (p.content.type === "text") output.push(p.content.text);
|
||||
});
|
||||
|
||||
emitter.on("tool_call", (p) => {
|
||||
console.log(`[tool] ${p.title}`)
|
||||
})
|
||||
emitter.on("tool_call", (p) => {
|
||||
console.log(`[tool] ${p.title}`);
|
||||
});
|
||||
|
||||
const { stopReason } = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: task }],
|
||||
})
|
||||
const { stopReason } = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: taskPrompt }],
|
||||
});
|
||||
|
||||
console.log("\n--- Agent Output ---")
|
||||
console.log(output.join(""))
|
||||
console.log(`\nStop reason: ${stopReason}`)
|
||||
console.log("\n--- Agent Output ---");
|
||||
console.log(output.join(""));
|
||||
console.log(`\nStop reason: ${stopReason}`);
|
||||
|
||||
await agent.shutdown()
|
||||
await agent.shutdown();
|
||||
}
|
||||
|
||||
runTask("Create a README.md for this project", process.cwd())
|
||||
runTask("Create a README.md for this project", process.cwd());
|
||||
```
|
||||
|
||||
## Full Example: Interactive Permission Flow
|
||||
|
||||
```typescript
|
||||
import { ClineAgent, type PermissionHandler } from "cline"
|
||||
import * as readline from "readline"
|
||||
import { ClineAgent, type PermissionHandler } from "cline";
|
||||
import * as readline from "readline";
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
const ask = (q: string) => new Promise<string>((res) => rl.question(q, res))
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const ask = (q: string) => new Promise<string>((res) => rl.question(q, res));
|
||||
|
||||
const interactivePermissions: PermissionHandler = async (request, resolve) => {
|
||||
console.log(`\n⚠️ Permission: ${request.toolCall.title}`)
|
||||
const interactivePermissions: PermissionHandler = async (request) => {
|
||||
console.log(`\n⚠️ Permission: ${request.toolCall.title}`);
|
||||
|
||||
for (const [i, opt] of request.options.entries()) {
|
||||
console.log(` ${i + 1}. [${opt.kind}] ${opt.name}`)
|
||||
}
|
||||
for (const [i, opt] of request.options.entries()) {
|
||||
console.log(` ${i + 1}. [${opt.kind}] ${opt.name}`);
|
||||
}
|
||||
|
||||
const choice = await ask("Choose (number): ")
|
||||
const idx = parseInt(choice, 10) - 1
|
||||
const selected = request.options[idx]
|
||||
const choice = await ask("Choose (number): ");
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
const selected = request.options[idx];
|
||||
|
||||
if (selected) {
|
||||
resolve({ outcome: { outcome: "selected", optionId: selected.optionId } })
|
||||
} else {
|
||||
resolve({ outcome: { outcome: "rejected" } })
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
return {
|
||||
outcome: { outcome: "selected", optionId: selected.optionId },
|
||||
};
|
||||
} else {
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const agent = new ClineAgent({ version: "1.0.0" })
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} })
|
||||
const agent = new ClineAgent({});
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
})
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
agent.setPermissionHandler(interactivePermissions)
|
||||
agent.setPermissionHandler(interactivePermissions);
|
||||
|
||||
const emitter = agent.emitterForSession(sessionId)
|
||||
emitter.on("agent_message_chunk", (p) => {
|
||||
if (p.content.type === "text") process.stdout.write(p.content.text)
|
||||
})
|
||||
const emitter = agent.emitterForSession(sessionId);
|
||||
emitter.on("agent_message_chunk", (p) => {
|
||||
if (p.content.type === "text") process.stdout.write(p.content.text);
|
||||
});
|
||||
|
||||
// Multi-turn conversation
|
||||
while (true) {
|
||||
const userInput = await ask("\n> ")
|
||||
if (userInput === "exit") break
|
||||
// Multi-turn conversation
|
||||
while (true) {
|
||||
const userInput = await ask("\n> ");
|
||||
if (userInput === "exit") break;
|
||||
|
||||
const { stopReason } = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: userInput }],
|
||||
})
|
||||
const { stopReason } = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: userInput }],
|
||||
});
|
||||
|
||||
console.log(`\n[${stopReason}]`)
|
||||
}
|
||||
console.log(`\n[${stopReason}]`);
|
||||
}
|
||||
|
||||
await agent.shutdown()
|
||||
rl.close()
|
||||
await agent.shutdown();
|
||||
rl.close();
|
||||
}
|
||||
|
||||
main()
|
||||
main();
|
||||
|
||||
```
|
||||
|
||||
## Exported Types
|
||||
@@ -610,23 +725,34 @@ All types are re-exported from the `cline` package. Key types:
|
||||
|------|-------------|
|
||||
| `ClineAgent` | Main agent class |
|
||||
| `ClineSessionEmitter` | Typed event emitter for session events |
|
||||
| `ClineAgentOptions` | Constructor options |
|
||||
| `ClineAgentOptions` | Constructor options (`debug`, `clineDir`, `hooksDir`) |
|
||||
| `ClineAcpSession` | Session metadata (read-only) |
|
||||
| `ClineSessionEvents` | Event name → handler signature map |
|
||||
| `PermissionHandler` | `(request, resolve) => void` callback |
|
||||
| `PermissionResolver` | `(response) => void` callback |
|
||||
| `AcpSessionStatus` | Session lifecycle enum: `Idle`, `Processing`, `Cancelled` |
|
||||
| `AcpSessionState` | Session state tracking (status, pending tool calls) |
|
||||
| `PermissionHandler` | `(request: RequestPermissionRequest) => Promise<RequestPermissionResponse>` |
|
||||
| `RequestPermissionRequest` | Permission request details (sessionId, toolCall, options) |
|
||||
| `RequestPermissionResponse` | Permission response with outcome |
|
||||
| `PermissionOption` | Permission choice (`kind`, `optionId`, `name`) |
|
||||
| `SessionUpdate` | Union of all session update types |
|
||||
| `SessionUpdateType` | Discriminator values (`"agent_message_chunk"`, `"tool_call"`, etc.) |
|
||||
| `SessionUpdatePayload` | Typed payload for a given `SessionUpdateType` |
|
||||
| `SessionModelState` | Current model and available models |
|
||||
| `ToolCall` | Tool call details (id, title, kind, status, content) |
|
||||
| `ToolCallUpdate` | Partial update to an existing tool call |
|
||||
| `ToolCallStatus` | `"pending" \| "in_progress" \| "completed" \| "failed"` |
|
||||
| `ToolKind` | `"read" \| "edit" \| "delete" \| "execute" \| "search" \| ...` |
|
||||
| `StopReason` | `"end_turn" \| "cancelled" \| "error" \| "max_tokens" \| ...` |
|
||||
| `ContentBlock` | `TextContent \| ImageContent \| AudioContent \| ...` |
|
||||
| `TextContent` / `ImageContent` / `AudioContent` | Individual content block types |
|
||||
| `McpServer` | MCP server configuration (stdio, http) |
|
||||
| `ModelInfo` | Model metadata (`modelId`, `name`) |
|
||||
| `PromptRequest` / `PromptResponse` | Prompt call types |
|
||||
| `NewSessionRequest` / `NewSessionResponse` | Session creation types |
|
||||
| `InitializeRequest` / `InitializeResponse` | Initialization types |
|
||||
| `SetSessionModeRequest` / `SetSessionModeResponse` | Mode switching types |
|
||||
| `SetSessionModelRequest` / `SetSessionModelResponse` | Model switching types |
|
||||
| `TranslatedMessage` | Result of translating a Cline message to ACP updates |
|
||||
|
||||
See the [ACP Schema](https://agentclientprotocol.com/protocol/schema) for the full type definitions.
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ Create a file called `file-logger` in your hooks directory with this content:
|
||||
# Logs all file operations to ~/cline-activity.log
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.tool')
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.toolName')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.preToolUse.parameters.path // "N/A"')
|
||||
|
||||
# Log to file
|
||||
@@ -143,16 +143,35 @@ echo '{"cancel":false}'
|
||||
|
||||
<Steps>
|
||||
<Step title="Create the hook file">
|
||||
Save the script above as `~/Documents/Cline/Hooks/file-logger` (macOS/Linux) or create it through the Hooks UI.
|
||||
Save the script above as `~/Documents/Cline/Hooks/file-logger` or create it through the Hooks UI.
|
||||
</Step>
|
||||
<Step title="Make it executable">
|
||||
Run `chmod +x ~/Documents/Cline/Hooks/file-logger` in your terminal.
|
||||
On macOS/Linux, run `chmod +x ~/Documents/Cline/Hooks/file-logger`.
|
||||
</Step>
|
||||
<Step title="Enable it">
|
||||
<Step title="Enable it (macOS/Linux only)">
|
||||
In Cline's Hooks tab, find "file-logger" under PreToolUse hooks and toggle it on.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
On Windows, hooks are executed with PowerShell and run whenever the hook file exists. In this
|
||||
foundation PR, hook enable/disable toggling is not yet supported on Windows.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Coming next: JSON-backed hook enabled/disabled state across platforms, so toggle behavior is
|
||||
consistent on Windows, macOS, and Linux.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Hook filenames are platform-specific:
|
||||
|
||||
- **Windows**: only `HookName.ps1` is supported (PowerShell script files)
|
||||
- **macOS/Linux**: only extensionless `HookName` is supported (executable files like bash scripts or binaries)
|
||||
|
||||
Wrong-platform naming is ignored by hook discovery.
|
||||
</Note>
|
||||
|
||||
### Test It
|
||||
|
||||
Ask Cline to read any file in your project: "What's in package.json?"
|
||||
@@ -190,23 +209,44 @@ Every hook receives a JSON object with common fields plus hook-specific data:
|
||||
```json
|
||||
{
|
||||
"taskId": "abc123",
|
||||
"hookName": "PreToolUse",
|
||||
"clineVersion": "3.17.0",
|
||||
"timestamp": 1736654400000,
|
||||
"workspacePath": "/path/to/project",
|
||||
"timestamp": "1736654400000",
|
||||
"workspaceRoots": ["/path/to/project"],
|
||||
"userId": "user_123",
|
||||
"model": {
|
||||
"provider": "openrouter",
|
||||
"slug": "anthropic/claude-sonnet-4.5"
|
||||
},
|
||||
|
||||
// Hook-specific field (name matches hook type in camelCase)
|
||||
"taskStart": {
|
||||
"task": "Add authentication to the API"
|
||||
"taskMetadata": {
|
||||
"taskId": "abc123",
|
||||
"ulid": "01J...",
|
||||
"initialTask": "Add authentication to the API"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`model.provider` and `model.slug` are machine-stable identifiers for the active provider/model at hook execution time. If unavailable, Cline sends deterministic fallback values: `"unknown"`.
|
||||
|
||||
<Note>
|
||||
Migration note for existing hook scripts:
|
||||
|
||||
- `timestamp` is a string (milliseconds since epoch), not a number
|
||||
- `workspaceRoots` is an array of workspace root paths and replaces the old singular `workspacePath`
|
||||
|
||||
If your scripts previously read `.workspacePath`, switch to `.workspaceRoots[0]` (or iterate all roots).
|
||||
</Note>
|
||||
|
||||
The hook-specific field name matches the hook type:
|
||||
- `taskStart`, `taskResume`, `taskCancel`, `taskComplete` contain `{ task: string }`
|
||||
- `preToolUse` contains `{ tool: string, parameters: object }`
|
||||
- `postToolUse` contains `{ tool: string, parameters: object, result: string, success: boolean, durationMs: number }`
|
||||
- `userPromptSubmit` contains `{ prompt: string }`
|
||||
- `preCompact` contains `{ conversationLength: number, estimatedTokens: number }`
|
||||
- `taskStart`, `taskResume`, `taskCancel`, `taskComplete` contain `{ taskMetadata: { taskId, ulid, ... } }`
|
||||
- `preToolUse` contains `{ toolName: string, parameters: object }`
|
||||
- `postToolUse` contains `{ toolName: string, parameters: object, result: string, success: boolean, executionTimeMs: number }`
|
||||
- `userPromptSubmit` contains `{ prompt: string, attachments: string[] }`
|
||||
- `preCompact` contains `{ taskId, ulid, contextSize, compactionStrategy, tokensIn, tokensOut, ... }`
|
||||
|
||||
### Output Structure
|
||||
|
||||
@@ -251,7 +291,7 @@ Runs when you start a new task. Use it to:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
INPUT=$(cat)
|
||||
TASK=$(echo "$INPUT" | jq -r '.taskStart.task')
|
||||
TASK=$(echo "$INPUT" | jq -r '.taskStart.taskMetadata.initialTask')
|
||||
echo "[TaskStart] Starting: $TASK" >&2
|
||||
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
|
||||
```
|
||||
@@ -293,7 +333,7 @@ The input includes the tool name and its parameters:
|
||||
```json
|
||||
{
|
||||
"preToolUse": {
|
||||
"tool": "write_to_file",
|
||||
"toolName": "write_to_file",
|
||||
"parameters": {
|
||||
"path": "src/config.ts",
|
||||
"content": "..."
|
||||
@@ -307,7 +347,7 @@ Example that blocks `.js` files in a TypeScript project:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
INPUT=$(cat)
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.tool')
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.toolName')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.preToolUse.parameters.path // empty')
|
||||
|
||||
if [[ "$TOOL" == "write_to_file" && "$FILE_PATH" == *.js ]]; then
|
||||
@@ -331,11 +371,11 @@ The input includes execution results:
|
||||
```json
|
||||
{
|
||||
"postToolUse": {
|
||||
"tool": "execute_command",
|
||||
"toolName": "execute_command",
|
||||
"parameters": { "command": "npm test" },
|
||||
"result": "All tests passed",
|
||||
"success": true,
|
||||
"durationMs": 3450
|
||||
"executionTimeMs": 3450
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -365,8 +405,14 @@ The input includes context metrics:
|
||||
```json
|
||||
{
|
||||
"preCompact": {
|
||||
"conversationLength": 45,
|
||||
"estimatedTokens": 125000
|
||||
"taskId": "abc123",
|
||||
"ulid": "01J...",
|
||||
"contextSize": 45,
|
||||
"compactionStrategy": "auto-condense",
|
||||
"tokensIn": 125000,
|
||||
"tokensOut": 8500,
|
||||
"tokensInCache": 0,
|
||||
"tokensOutCache": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -382,7 +428,7 @@ Block creation of `.js` files in a TypeScript project:
|
||||
# PreToolUse hook
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.tool')
|
||||
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.toolName')
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.preToolUse.parameters.path // empty')
|
||||
|
||||
if [[ "$TOOL" == "write_to_file" && "$FILE_PATH" == *.js ]]; then
|
||||
@@ -402,9 +448,9 @@ Log all tool executions to a file:
|
||||
# PostToolUse hook
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL=$(echo "$INPUT" | jq -r '.postToolUse.tool')
|
||||
TOOL=$(echo "$INPUT" | jq -r '.postToolUse.toolName')
|
||||
SUCCESS=$(echo "$INPUT" | jq -r '.postToolUse.success')
|
||||
DURATION=$(echo "$INPUT" | jq -r '.postToolUse.durationMs')
|
||||
DURATION=$(echo "$INPUT" | jq -r '.postToolUse.executionTimeMs')
|
||||
|
||||
echo "$(date -Iseconds) | $TOOL | success=$SUCCESS | ${DURATION}ms" >> ~/.cline-tool-log.txt
|
||||
|
||||
@@ -420,7 +466,7 @@ Inject project-specific information when a task begins:
|
||||
# TaskStart hook
|
||||
|
||||
INPUT=$(cat)
|
||||
WORKSPACE=$(echo "$INPUT" | jq -r '.workspacePath')
|
||||
WORKSPACE=$(echo "$INPUT" | jq -r '.workspaceRoots[0] // empty')
|
||||
|
||||
# Read project info if available
|
||||
if [[ -f "$WORKSPACE/.project-context" ]]; then
|
||||
@@ -444,14 +490,17 @@ cline config set hooks-enabled=true
|
||||
```
|
||||
|
||||
<Note>
|
||||
CLI hooks are only supported on macOS and Linux.
|
||||
Windows hooks require PowerShell (`powershell.exe`) available on your PATH.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Hook not running?**
|
||||
- Check that the file is executable (`chmod +x hookname`)
|
||||
- Verify the hook is enabled (toggle is on in the Hooks tab)
|
||||
- On macOS/Linux, check that the file is executable (`chmod +x hookname`)
|
||||
- On Windows, ensure PowerShell is available (`powershell -NoProfile -Command "$PSVersionTable.PSVersion"`)
|
||||
- On Windows, ensure the hook file is named `<HookName>.ps1` (for example `PreToolUse.ps1`)
|
||||
- On macOS/Linux, ensure the hook file uses extensionless `<HookName>` naming (for example `PreToolUse`)
|
||||
- On macOS/Linux, verify the hook is enabled (toggle is on in the Hooks tab)
|
||||
- Check that Hooks are enabled globally in Settings
|
||||
|
||||
**Hook output not parsed?**
|
||||
|
||||
+77
-10
@@ -101,6 +101,7 @@
|
||||
"pages": [
|
||||
"cline-cli/overview",
|
||||
"cline-cli/installation",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/interactive-mode",
|
||||
{
|
||||
"group": "Headless Mode",
|
||||
@@ -257,7 +258,7 @@
|
||||
"enterprise-solutions/sso-setup",
|
||||
"enterprise-solutions/team-management/managing-members",
|
||||
{
|
||||
"group": "SaaS Provider Configuration",
|
||||
"group": "Remote Provider Configuration",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/overview",
|
||||
{
|
||||
@@ -267,19 +268,33 @@
|
||||
"enterprise-solutions/configuration/remote-configuration/aws-bedrock/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "LiteLLM",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Google Vertex AI",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/google-vertex/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAI Compatible",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/openai-compatible/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/openai-compatible/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Anthropic",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/anthropic/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/anthropic/member-configuration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "LiteLLM",
|
||||
"pages": [
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/admin-configuration",
|
||||
"enterprise-solutions/configuration/remote-configuration/litellm/member-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -295,9 +310,57 @@
|
||||
"pages": [
|
||||
"enterprise-solutions/monitoring/overview",
|
||||
"enterprise-solutions/monitoring/telemetry",
|
||||
"enterprise-solutions/monitoring/opentelemetry"
|
||||
"enterprise-solutions/monitoring/prompt-storage",
|
||||
"enterprise-solutions/monitoring/opentelemetry",
|
||||
"enterprise-solutions/monitoring/opentelemetry-events",
|
||||
"enterprise-solutions/monitoring/opentelemetry_override"
|
||||
]
|
||||
}
|
||||
},
|
||||
"enterprise-solutions/api-reference"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "API",
|
||||
"icon": "code",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Cline API",
|
||||
"pages": [
|
||||
"api/overview",
|
||||
"api/getting-started",
|
||||
"api/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Endpoints",
|
||||
"pages": [
|
||||
"api/chat-completions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Reference",
|
||||
"pages": [
|
||||
"api/models",
|
||||
"api/errors",
|
||||
"api/sdk-examples"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Kanban",
|
||||
"icon": "table-columns",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Cline Kanban",
|
||||
"pages": [
|
||||
"kanban/overview",
|
||||
"kanban/getting-started",
|
||||
"kanban/core-workflow",
|
||||
"kanban/features",
|
||||
"kanban/remote-access"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -607,6 +670,10 @@
|
||||
{
|
||||
"source": "/features/skills",
|
||||
"destination": "/customization/skills"
|
||||
},
|
||||
{
|
||||
"source": "/api/reference",
|
||||
"destination": "/api/overview"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user