mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37875df8e7 | ||
|
|
743c41feb7 | ||
|
|
329629c279 | ||
|
|
c44b29b002 | ||
|
|
bb4e397a51 | ||
|
|
17e6151e2b | ||
|
|
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 | ||
|
|
4673cbe55d | ||
|
|
0e9c451fcc | ||
|
|
fd8cecddd5 | ||
|
|
a502bd8653 | ||
|
|
d48d5ee74d | ||
|
|
cd320ea01f | ||
|
|
60485277c4 | ||
|
|
0a8f1ef248 | ||
|
|
4b2619daf7 | ||
|
|
913cf4b74d | ||
|
|
8e5be3f648 | ||
|
|
6c519ff6e5 | ||
|
|
c61f9a9394 | ||
|
|
fa53f301a4 | ||
|
|
c36e375af5 | ||
|
|
94b02bf052 |
@@ -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,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,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
|
||||
@@ -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
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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,10 +40,69 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
AUTO_CREATE="${{ github.event.inputs.auto_create_tag_from_main }}"
|
||||
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
|
||||
with:
|
||||
@@ -59,20 +123,15 @@ 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 '$TAG' validated successfully"
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
@@ -103,7 +162,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 +178,12 @@ 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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
- Add dynamic Cline provider model fetching from Cline endpoint
|
||||
- Add additional Markdown formatting in CLI
|
||||
- Add focus indicator on action buttons in extension
|
||||
|
||||
### Fixed
|
||||
|
||||
- Clear all OCA secrets on auth refresh failure to prevent re-auth loops
|
||||
- Resolve "Could not find the file context" error in Explain Changes
|
||||
- Use `JSON_SCHEMA` for `yaml.load` to prevent unsafe deserialization
|
||||
- Fetch model info from API in CLI headless auth for Cline and Vercel providers
|
||||
- Generate commit message from staged changes only when staging exists
|
||||
- Update stale `maxTokens` values for Claude 3.7+ models across Anthropic, Bedrock, Vertex, and SAP AI Core
|
||||
- Use `model.info.maxTokens` for OpenRouter instead of hardcoded `8192`
|
||||
|
||||
### Changed
|
||||
|
||||
- Increase timeout for a flaky test to reduce short-term test instability
|
||||
|
||||
## [3.67.1]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# cline
|
||||
|
||||
## [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
|
||||
|
||||
- Expanded CLI markdown rendering support (headings, lists, blockquotes, fenced code blocks, links, and nested lists).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed CLI headless auth provider model metadata loading for Cline and Vercel AI Gateway by fetching model info from API with cache fallback.
|
||||
- Increased flaky CLI import test timeout on Windows CI to reduce intermittent test failures.
|
||||
|
||||
## [2.5.0]
|
||||
|
||||
### 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.0",
|
||||
"version": "2.7.1",
|
||||
"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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,7 +120,9 @@ describe("SkillsPanelContent", () => {
|
||||
await delay()
|
||||
|
||||
// Navigate down to marketplace (past the one skill)
|
||||
stdin.write("\x1B[B") // Down arrow
|
||||
// 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 delay()
|
||||
|
||||
stdin.write("\r") // Enter
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
+144
-1
@@ -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
|
||||
@@ -25,6 +26,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 +35,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 +67,22 @@ 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 npx kanban --agent cline")
|
||||
.action(() => {})
|
||||
|
||||
// Default command for interactive mode
|
||||
program
|
||||
.argument("[prompt]", "Task prompt")
|
||||
@@ -72,6 +93,11 @@ 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("--kanban", "Run npx kanban --agent cline")
|
||||
.action(() => {})
|
||||
})
|
||||
|
||||
@@ -108,6 +134,13 @@ describe("CLI Commands", () => {
|
||||
expect(taskCmd.opts().yolo).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse --auto-approve-all flag", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "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 args = ["test prompt", "--model", "claude-sonnet-4-20250514"]
|
||||
@@ -171,6 +204,27 @@ describe("CLI Commands", () => {
|
||||
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
|
||||
})
|
||||
|
||||
it("should parse --hooks-dir option", () => {
|
||||
const taskCmd = program.commands.find((c) => c.name() === "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 = program.commands.find((c) => c.name() === "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 = program.commands.find((c) => c.name() === "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 args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
|
||||
@@ -237,6 +291,13 @@ describe("CLI Commands", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("kanban command", () => {
|
||||
it("should parse kanban command", () => {
|
||||
const args = ["node", "cli", "kanban"]
|
||||
program.parse(args)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth command", () => {
|
||||
it("should parse auth command", () => {
|
||||
const args = ["node", "cli", "auth"]
|
||||
@@ -281,6 +342,32 @@ 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 mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "add")!
|
||||
addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" })
|
||||
expect(addCmd.opts().type).toBe("stdio")
|
||||
})
|
||||
|
||||
it("should parse mcp add type option", () => {
|
||||
const mcpCmd = program.commands.find((c) => c.name() === "mcp")!
|
||||
const addCmd = mcpCmd.commands.find((c) => c.name() === "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 +408,21 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command structure", () => {
|
||||
@@ -330,6 +432,8 @@ describe("CLI Commands", () => {
|
||||
expect(commandNames).toContain("history")
|
||||
expect(commandNames).toContain("config")
|
||||
expect(commandNames).toContain("auth")
|
||||
expect(commandNames).toContain("mcp")
|
||||
expect(commandNames).toContain("kanban")
|
||||
})
|
||||
|
||||
it("should have correct aliases", () => {
|
||||
@@ -410,3 +514,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()
|
||||
})
|
||||
})
|
||||
|
||||
+205
-29
@@ -2,6 +2,7 @@
|
||||
* Cline CLI - TypeScript implementation with React Ink
|
||||
*/
|
||||
|
||||
import { spawn } 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"
|
||||
@@ -32,6 +35,7 @@ import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
|
||||
import { isAuthConfigured } from "./utils/auth"
|
||||
import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console"
|
||||
import { printInfo, printWarning } from "./utils/display"
|
||||
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 +43,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 +58,23 @@ suppressConsoleUnlessVerbose()
|
||||
interface TaskOptions {
|
||||
act?: boolean
|
||||
plan?: boolean
|
||||
kanban?: 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 +143,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 +187,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 +205,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 +251,36 @@ function getPlainTextModeReason(options: TaskOptions): string {
|
||||
return getModeSelection(options).reason
|
||||
}
|
||||
|
||||
function getNpxCommand(): string {
|
||||
return process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
}
|
||||
|
||||
function runKanbanAlias(): void {
|
||||
const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", () => {
|
||||
printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.")
|
||||
exit(1)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
exit(code ?? 1)
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -316,6 +364,42 @@ 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 (isShuttingDown) {
|
||||
@@ -353,7 +437,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 +463,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 +487,7 @@ interface CliContext {
|
||||
interface InitOptions {
|
||||
config?: string
|
||||
cwd?: string
|
||||
hooksDir?: string
|
||||
verbose?: boolean
|
||||
enableAuth?: boolean
|
||||
}
|
||||
@@ -403,6 +497,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 +599,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 +696,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 +828,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 +839,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 +876,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")
|
||||
@@ -784,6 +899,8 @@ program
|
||||
.option("-v, --verbose", "Show verbose output")
|
||||
.action(() => checkForUpdates(CLI_VERSION))
|
||||
|
||||
program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias)
|
||||
|
||||
// Dev command with subcommands
|
||||
const devCommand = program.command("dev").description("Developer tools and utilities")
|
||||
|
||||
@@ -808,8 +925,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 +939,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 +978,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 +1036,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 +1047,29 @@ 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("--kanban", "Run npx kanban --agent cline")
|
||||
.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) {
|
||||
if (prompt) {
|
||||
printWarning("Use --kanban without a prompt.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
runKanbanAlias()
|
||||
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 +1084,25 @@ program
|
||||
// stdinInput has content means stdin was piped with data
|
||||
const stdinWasPiped = stdinInput !== null
|
||||
|
||||
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 +1123,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 +1149,6 @@ program
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
program.parse()
|
||||
if (process.env.VITEST !== "true") {
|
||||
program.parse()
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ describe("library import side effects", () => {
|
||||
const originalConsoleLog = console.log
|
||||
await import("./exports")
|
||||
expect(console.log).toBe(originalConsoleLog)
|
||||
}, 10000)
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type { ApiProvider } from "@shared/api"
|
||||
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage"
|
||||
import { buildApiHandler } from "@/core/api"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels"
|
||||
import { refreshVercelAiGatewayModels } from "@/core/controller/models/refreshVercelAiGatewayModels"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import type { BedrockConfig } from "../components/BedrockSetup"
|
||||
import { getDefaultModelId } from "../components/ModelPicker"
|
||||
@@ -40,14 +42,22 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
|
||||
if (actModelKey) config[actModelKey] = finalModelId
|
||||
if (planModelKey) config[planModelKey] = finalModelId
|
||||
|
||||
// For cline/openrouter, also set model info (required for getModel() to return correct model)
|
||||
// Fetch model info from the provider API (not just disk cache) so headless
|
||||
// CLI auth gets correct maxTokens, thinkingConfig, etc.
|
||||
if ((providerId === "cline" || providerId === "openrouter") && controller) {
|
||||
const openRouterModels = await controller.readOpenRouterModels()
|
||||
const openRouterModels = await refreshOpenRouterModels(controller)
|
||||
const modelInfo = openRouterModels?.[finalModelId]
|
||||
if (modelInfo) {
|
||||
stateManager.setGlobalState("actModeOpenRouterModelInfo", modelInfo)
|
||||
stateManager.setGlobalState("planModeOpenRouterModelInfo", modelInfo)
|
||||
}
|
||||
} else if (providerId === "vercel-ai-gateway" && controller) {
|
||||
const vercelModels = await refreshVercelAiGatewayModels(controller)
|
||||
const modelInfo = vercelModels?.[finalModelId]
|
||||
if (modelInfo) {
|
||||
stateManager.setGlobalState("actModeVercelAiGatewayModelInfo", modelInfo)
|
||||
stateManager.setGlobalState("planModeVercelAiGatewayModelInfo", modelInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,114 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## Popular Models
|
||||
|
||||
| Model ID | Provider | Context Window | Reasoning | Best For |
|
||||
|----------|----------|---------------|-----------|----------|
|
||||
| `anthropic/claude-sonnet-4-6` | Anthropic | 200K | Yes | General coding, analysis, complex tasks |
|
||||
| `anthropic/claude-sonnet-4-5` | Anthropic | 200K | Yes | Balanced performance and cost |
|
||||
| `openai/gpt-4o` | OpenAI | 128K | No | Multimodal tasks, fast responses |
|
||||
| `google/gemini-2.5-pro` | Google | 1M | Yes | Very long context, document analysis |
|
||||
| `deepseek/deepseek-chat` | DeepSeek | 64K | No | Cost-effective coding tasks |
|
||||
| `x-ai/grok-3` | xAI | 128K | Yes | Reasoning-heavy tasks |
|
||||
|
||||
<Note>
|
||||
Model availability and pricing change over time. Check [app.cline.bot](https://app.cline.bot) for the latest catalog.
|
||||
</Note>
|
||||
|
||||
## Free Models
|
||||
|
||||
These models are available at no cost. They are a good starting point for experimentation and lightweight tasks:
|
||||
|
||||
| Model ID | Provider | Context Window |
|
||||
|----------|----------|---------------|
|
||||
| `minimax/minimax-m2.5` | MiniMax | 1M |
|
||||
| `kwaipilot/kat-coder-pro` | Kwaipilot | 32K |
|
||||
| `z-ai/glm-5` | Z-AI | 128K |
|
||||
|
||||
Free models have the same API interface as paid models. Just use their model ID:
|
||||
|
||||
```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="SDK 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: "SDK Examples"
|
||||
sidebarTitle: "SDK 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
|
||||
|
||||
+158
-126
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: "Cline SDK"
|
||||
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 +24,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 +105,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 +115,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
|
||||
})
|
||||
```
|
||||
|
||||
@@ -160,9 +185,7 @@ await agent.prompt({
|
||||
| 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 |
|
||||
|
||||
### Streaming Events
|
||||
|
||||
@@ -220,7 +243,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 +251,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" } }
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -265,7 +288,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 +301,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 +319,7 @@ 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, configure the key through the cline config directory before creating a session. The `authenticate()` call is not needed for BYO providers. We plan to support more auth providers in the near future.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -306,8 +329,6 @@ Cancel an in-progress prompt turn:
|
||||
await agent.cancel({ sessionId })
|
||||
```
|
||||
|
||||
The pending `prompt()` call will resolve with `{ stopReason: "cancelled" }`.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
@@ -318,8 +339,6 @@ 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) */
|
||||
@@ -331,7 +350,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",
|
||||
})
|
||||
```
|
||||
@@ -434,7 +452,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 +469,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.
|
||||
@@ -490,116 +516,122 @@ for (const [sessionId, session] of agent.sessions) {
|
||||
## 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: "/Users/maxpaulus/.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
|
||||
|
||||
@@ -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,9 +209,15 @@ 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": {
|
||||
@@ -201,6 +226,17 @@ Every hook receives a JSON object with common fields plus hook-specific data:
|
||||
}
|
||||
```
|
||||
|
||||
`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 }`
|
||||
@@ -420,7 +456,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 +480,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?**
|
||||
|
||||
+35
-1
@@ -116,6 +116,7 @@
|
||||
},
|
||||
"cline-cli/configuration",
|
||||
"cline-cli/acp-editor-integrations",
|
||||
"cline-sdk/overview",
|
||||
"cline-cli/cli-reference"
|
||||
]
|
||||
},
|
||||
@@ -297,7 +298,36 @@
|
||||
"enterprise-solutions/monitoring/telemetry",
|
||||
"enterprise-solutions/monitoring/opentelemetry"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -607,6 +637,10 @@
|
||||
{
|
||||
"source": "/features/skills",
|
||||
"destination": "/customization/skills"
|
||||
},
|
||||
{
|
||||
"source": "/api/reference",
|
||||
"destination": "/api/overview"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: "Enterprise API Reference"
|
||||
sidebarTitle: "API Reference"
|
||||
description: "REST API endpoints for managing users, organizations, billing, plans, and API keys."
|
||||
---
|
||||
|
||||
The Enterprise API provides REST endpoints for account management, organization administration, billing, and API key management. These are separate from the [Chat Completions API](/api/reference), which handles model inference.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://api.cline.bot
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require a Bearer token in the `Authorization` header:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer YOUR_AUTH_TOKEN
|
||||
```
|
||||
|
||||
Use the same API key or account auth token described in the [public API reference](/api/reference#authentication).
|
||||
|
||||
## Quick Example
|
||||
|
||||
```bash
|
||||
# Get your user profile
|
||||
curl https://api.cline.bot/api/v1/users/me \
|
||||
-H "Authorization: Bearer YOUR_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "user_abc123",
|
||||
"email": "you@company.com",
|
||||
"name": "Your Name",
|
||||
"active_account_id": "org_xyz789"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
Manage user accounts, accept terms, check balances, view usage, and configure payment methods.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/users/me` | Get current user profile |
|
||||
| `PATCH` | `/api/v1/users/me` | Update current user profile |
|
||||
| `DELETE` | `/api/v1/users/me` | Delete current user account |
|
||||
| `POST` | `/api/v1/users/me/accept-terms` | Accept terms of service |
|
||||
| `GET` | `/api/v1/users/me/remote-config` | Get remote configuration for the current user |
|
||||
| `PUT` | `/api/v1/users/active-account` | Switch active account (personal or organization) |
|
||||
| `GET` | `/api/v1/users/{id}/balance` | Get credit balance |
|
||||
| `GET` | `/api/v1/users/{id}/usages` | Get usage history |
|
||||
|
||||
### Payments and Credits
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/users/{id}/payments` | List payment history |
|
||||
| `GET` | `/api/v1/users/{id}/payments/{paymentId}` | Get payment details |
|
||||
| `GET` | `/api/v1/users/{id}/payments/{paymentId}/status` | Check payment status |
|
||||
| `GET` | `/api/v1/users/{id}/payments/provider/{paymentId}` | Get provider-side payment details |
|
||||
| `POST` | `/api/v1/users/credits/checkout` | Start a credit purchase checkout |
|
||||
| `POST` | `/api/v1/users/{id}/credits/purchase` | Purchase credits directly |
|
||||
|
||||
### Billing Configuration
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/users/{id}/auto-top-up` | Get auto top-up settings |
|
||||
| `PUT` | `/api/v1/users/{id}/auto-top-up` | Configure auto top-up |
|
||||
| `GET` | `/api/v1/users/{id}/payment-method/default` | Get default payment method |
|
||||
| `POST` | `/api/v1/users/{id}/payment-method/setup-session` | Start payment method setup |
|
||||
| `GET` | `/api/v1/users/{id}/promotions` | List active promotions |
|
||||
|
||||
---
|
||||
|
||||
## Organizations
|
||||
|
||||
Create and manage organizations. Organization admins can configure remote settings, manage members, and control billing.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/v1/organizations` | Create a new organization |
|
||||
| `GET` | `/api/v1/organizations/{id}` | Get organization details |
|
||||
| `PUT` | `/api/v1/organizations/{id}` | Update organization settings |
|
||||
| `DELETE` | `/api/v1/organizations/{id}` | Delete an organization |
|
||||
| `GET` | `/api/v1/organizations/{id}/api-keys` | List organization API keys |
|
||||
| `GET` | `/api/v1/organizations/{id}/remote-config` | Get remote config for the org |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/metrics` | Get organization usage metrics |
|
||||
|
||||
---
|
||||
|
||||
## Organization Members
|
||||
|
||||
Manage who has access to the organization and what role they hold.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/{orgId}/members` | List all members |
|
||||
| `DELETE` | `/api/v1/organizations/{orgId}/members` | Remove members |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/members/available-roles` | List assignable roles |
|
||||
| `PUT` | `/api/v1/organizations/{orgId}/members/{memberId}/role` | Change a member's role |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/members/{memberId}/usages` | Get a member's usage |
|
||||
|
||||
<Tip>
|
||||
For a walkthrough of member management in the UI, see [Managing Members](/enterprise-solutions/team-management/managing-members).
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Organization Invites
|
||||
|
||||
Invite new members to join your organization.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/{orgId}/invites` | List pending invites |
|
||||
| `POST` | `/api/v1/organizations/{orgId}/invites` | Send new invites |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/invites/count` | Get invite count |
|
||||
| `DELETE` | `/api/v1/organizations/{orgId}/invites/{inviteId}` | Revoke an invite |
|
||||
| `POST` | `/api/v1/invites/accept` | Accept an invite (called by the invitee) |
|
||||
|
||||
---
|
||||
|
||||
## Organization Balance and Payments
|
||||
|
||||
Manage credits and payments at the organization level. These mirror the user-level payment endpoints but operate on the organization's account.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/{orgId}/balance` | Get org credit balance |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/payments` | List payment history |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/payments/{paymentId}` | Get payment details |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/payments/{paymentId}/status` | Check payment status |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/payments/provider/{paymentId}` | Provider-side payment details |
|
||||
| `POST` | `/api/v1/organizations/{orgId}/credits/checkout` | Start credit checkout |
|
||||
| `POST` | `/api/v1/organizations/{orgId}/credits/purchase` | Purchase credits |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/auto-top-up` | Get auto top-up config |
|
||||
| `PUT` | `/api/v1/organizations/{orgId}/auto-top-up` | Configure auto top-up |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/payment-method/default` | Get default payment method |
|
||||
| `POST` | `/api/v1/organizations/{orgId}/payment-method/setup-session` | Start payment method setup |
|
||||
| `GET` | `/api/v1/organizations/{id}/promotions` | List active promotions |
|
||||
|
||||
---
|
||||
|
||||
## Organization Plans
|
||||
|
||||
Subscribe to, upgrade, or cancel plans. Manage seat counts for your team.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/plans` | List all available plans |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/plan` | Get current plan |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/plan/history` | View plan change history |
|
||||
| `GET` | `/api/v1/organizations/{orgId}/plan/{planId}` | Get specific plan details |
|
||||
| `POST` | `/api/v1/organizations/{orgId}/plan` | Subscribe to a plan |
|
||||
| `PUT` | `/api/v1/organizations/{orgId}/plan/seats` | Update seat count |
|
||||
| `DELETE` | `/api/v1/organizations/{orgId}/plan/{planId}` | Cancel a plan |
|
||||
|
||||
---
|
||||
|
||||
## Organization Usage
|
||||
|
||||
Track token consumption and costs across your organization.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/organizations/{orgId}/usages` | Get aggregated usage data |
|
||||
|
||||
<Tip>
|
||||
For dashboards and monitoring, see [Monitoring Overview](/enterprise-solutions/monitoring/overview) and [Telemetry](/enterprise-solutions/monitoring/telemetry).
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## API Keys
|
||||
|
||||
Create and manage API keys for programmatic access. Keys created here work with both the [Chat Completions API](/api/reference) and the endpoints on this page.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/api-keys` | List your API keys |
|
||||
| `POST` | `/api/v1/api-keys` | Create a new API key |
|
||||
| `DELETE` | `/api/v1/api-keys/{key_id}` | Delete an API key |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Chat Completions API" icon="code" href="/api/reference">
|
||||
The public inference API for sending prompts and receiving completions.
|
||||
</Card>
|
||||
<Card title="SSO Setup" icon="key" href="/enterprise-solutions/sso-setup">
|
||||
Configure single sign-on for your organization.
|
||||
</Card>
|
||||
<Card title="Managing Members" icon="users" href="/enterprise-solutions/team-management/managing-members">
|
||||
Add, remove, and manage member roles in the UI.
|
||||
</Card>
|
||||
<Card title="Monitoring" icon="chart-line" href="/enterprise-solutions/monitoring/overview">
|
||||
Track usage, costs, and telemetry across your organization.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -5,7 +5,7 @@ description: "Learn how to configure and use Oracle Code Assist with Cline. Acce
|
||||
|
||||
Oracle Code Assist provides AI-powered coding assistance through Oracle Cloud Infrastructure (OCI) Generative AI service.
|
||||
|
||||
**Website:** [https://www.oracle.com/artificial-intelligence/code-assist/](https://www.oracle.com/artificial-intelligence/code-assist/)
|
||||
**Website:** [https://www.oracle.com/application-development/code-assist/](https://www.oracle.com/application-development/code-assist/)
|
||||
|
||||
### Getting Started
|
||||
|
||||
|
||||
@@ -177,4 +177,22 @@ describe("FailureClassifier", () => {
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("YAML Safety (JSON_SCHEMA)", () => {
|
||||
it("rejects patterns file with custom YAML tags", () => {
|
||||
// Write a temp YAML file with a !!js/function tag
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
const tmpFile = path.join(os.tmpdir(), "unsafe-patterns.yaml")
|
||||
fs.writeFileSync(
|
||||
tmpFile,
|
||||
`version: "1.0"\npatterns:\n - name: !!js/function 'function(){ return "pwned" }'\n`,
|
||||
)
|
||||
|
||||
expect(() => new FailureClassifier(tmpFile)).toThrow()
|
||||
|
||||
fs.unlinkSync(tmpFile)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ export class FailureClassifier {
|
||||
|
||||
private loadPatternsFromYaml(filePath: string): FailurePatternsConfig {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const config = yaml.load(content) as FailurePatternsConfig
|
||||
const config = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as FailurePatternsConfig
|
||||
|
||||
if (!config.version || !config.patterns) {
|
||||
throw new Error("Invalid patterns YAML: missing version or patterns")
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
* npx tsx evals/smoke-tests/run-smoke-tests.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --provider <name> Run tests for a specific provider (default: all configured)
|
||||
* --trials <n> Number of trials per test (default: 3)
|
||||
* --scenario <name> Run a specific scenario (default: all)
|
||||
* --model <id> Override model for all scenarios
|
||||
* --output <file> Write JSON results to file
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from "child_process"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as dotenv from "dotenv"
|
||||
import { MetricsCalculator } from "../analysis/src/metrics"
|
||||
|
||||
// Default provider and model for smoke tests
|
||||
@@ -43,24 +44,23 @@ function checkClineCli(): boolean {
|
||||
// Use user's existing Cline config (already has auth configured)
|
||||
// For CI, this would be set up by the auth step before tests run
|
||||
const CLINE_CONFIG_DIR = path.join(process.env.HOME || "", ".cline")
|
||||
const configuredAuthCache = new Set<string>()
|
||||
|
||||
// Configure authentication using CLINE_API_KEY environment variable
|
||||
// Returns success if auth is configured, error message otherwise
|
||||
function configureAuth(): { ok: boolean; error?: string } {
|
||||
const apiKey = process.env.CLINE_API_KEY
|
||||
if (!apiKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "CLINE_API_KEY environment variable not set",
|
||||
}
|
||||
}
|
||||
|
||||
function configureAuth(options: { provider: string; apiKey: string; modelId: string; baseUrl?: string }): {
|
||||
ok: boolean
|
||||
error?: string
|
||||
} {
|
||||
// Ensure config directory exists
|
||||
fs.mkdirSync(CLINE_CONFIG_DIR, { recursive: true })
|
||||
|
||||
try {
|
||||
// Run quick auth setup (non-interactive when all flags provided)
|
||||
execSync(`cline auth --config "${CLINE_CONFIG_DIR}" -p ${DEFAULT_PROVIDER} -k "${apiKey}" -m "${DEFAULT_MODEL}"`, {
|
||||
const args = [`cline auth --config "${CLINE_CONFIG_DIR}"`, `-p "${options.provider}"`, `-k "${options.apiKey}"`, `-m "${options.modelId}"`]
|
||||
if (options.baseUrl) {
|
||||
args.push(`-b "${options.baseUrl}"`)
|
||||
}
|
||||
|
||||
execSync(args.join(" "), {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
stdio: "pipe",
|
||||
@@ -74,6 +74,22 @@ function configureAuth(): { ok: boolean; error?: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function loadEnvFiles(): void {
|
||||
const repoRoot = path.resolve(__dirname, "..", "..")
|
||||
const envFiles = [path.join(repoRoot, ".env"), path.join(repoRoot, ".env.local")]
|
||||
for (const envPath of envFiles) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath, override: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ScenarioAuthConfig {
|
||||
apiKeyEnv?: string
|
||||
baseUrlEnv?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
// Smoke test scenario definition
|
||||
interface SmokeScenario {
|
||||
id: string
|
||||
@@ -85,6 +101,9 @@ interface SmokeScenario {
|
||||
expectedContent?: { file: string; contains: string }[] // Content checks
|
||||
timeout: number // Seconds
|
||||
models?: string[] // Optional: override default models for this scenario
|
||||
provider?: string // Provider override (defaults to DEFAULT_PROVIDER)
|
||||
requiredEnv?: string[] // Env vars required for this scenario to run
|
||||
auth?: ScenarioAuthConfig // Optional auth overrides for provider-specific scenarios
|
||||
}
|
||||
|
||||
// Load scenarios from disk
|
||||
@@ -108,6 +127,54 @@ function loadScenarios(scenariosDir: string): SmokeScenario[] {
|
||||
return scenarios
|
||||
}
|
||||
|
||||
function getMissingEnvVars(requiredEnv: string[] | undefined): string[] {
|
||||
if (!requiredEnv || requiredEnv.length === 0) {
|
||||
return []
|
||||
}
|
||||
return requiredEnv.filter((key) => !process.env[key])
|
||||
}
|
||||
|
||||
function getScenarioProvider(scenario: SmokeScenario): string {
|
||||
return scenario.provider || DEFAULT_PROVIDER
|
||||
}
|
||||
|
||||
function ensureScenarioAuth(scenario: SmokeScenario, modelId: string): { ok: boolean; error?: string } {
|
||||
const provider = getScenarioProvider(scenario)
|
||||
const authModelId = scenario.auth?.modelId || modelId
|
||||
const apiKeyEnv = scenario.auth?.apiKeyEnv || (provider === DEFAULT_PROVIDER ? "CLINE_API_KEY" : undefined)
|
||||
const apiKey = apiKeyEnv ? process.env[apiKeyEnv] : undefined
|
||||
const baseUrl = scenario.auth?.baseUrlEnv ? process.env[scenario.auth.baseUrlEnv] : undefined
|
||||
const authCacheKey = `${provider}|${authModelId}|${baseUrl || ""}|${apiKeyEnv || ""}`
|
||||
|
||||
if (apiKey) {
|
||||
if (configuredAuthCache.has(authCacheKey)) {
|
||||
return { ok: true }
|
||||
}
|
||||
const result = configureAuth({ provider, apiKey, modelId: authModelId, baseUrl })
|
||||
if (result.ok) {
|
||||
configuredAuthCache.add(authCacheKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// For default provider, local developers can rely on existing ~/.cline auth.
|
||||
if (provider === DEFAULT_PROVIDER) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (!apiKeyEnv) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Provider '${provider}' requires auth.apiKeyEnv in scenario config or preconfigured credentials`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Missing required auth env var '${apiKeyEnv}' for provider '${provider}'`,
|
||||
}
|
||||
}
|
||||
|
||||
// Run a single trial
|
||||
interface TrialResult {
|
||||
passed: boolean
|
||||
@@ -318,6 +385,8 @@ interface SmokeTestReport {
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
loadEnvFiles()
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
// Parse arguments
|
||||
@@ -357,24 +426,6 @@ async function main() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Configure authentication if CLINE_API_KEY is set
|
||||
// Otherwise use existing auth from ~/.cline
|
||||
if (process.env.CLINE_API_KEY) {
|
||||
console.log("Configuring authentication from CLINE_API_KEY...")
|
||||
const authResult = configureAuth()
|
||||
if (!authResult.ok) {
|
||||
console.error("")
|
||||
console.error("ERROR: Authentication failed")
|
||||
console.error(` ${authResult.error}`)
|
||||
console.error("")
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("Authentication configured")
|
||||
} else {
|
||||
console.log("Using existing authentication from ~/.cline")
|
||||
}
|
||||
console.log("")
|
||||
|
||||
// Load scenarios
|
||||
const scenariosDir = path.join(__dirname, "scenarios")
|
||||
let scenarios = loadScenarios(scenariosDir)
|
||||
@@ -392,12 +443,40 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const skippedByEnv: Array<{ id: string; missingEnv: string[] }> = []
|
||||
if (selectedScenario) {
|
||||
const missingEnv = getMissingEnvVars(scenarios[0].requiredEnv)
|
||||
if (missingEnv.length > 0) {
|
||||
console.error(`Scenario '${selectedScenario}' missing required env: ${missingEnv.join(", ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
scenarios = scenarios.filter((scenario) => {
|
||||
const missingEnv = getMissingEnvVars(scenario.requiredEnv)
|
||||
if (missingEnv.length > 0) {
|
||||
skippedByEnv.push({ id: scenario.id, missingEnv })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Filter models
|
||||
let models = MODELS
|
||||
if (selectedModel) {
|
||||
models = [selectedModel]
|
||||
}
|
||||
|
||||
if (scenarios.length === 0) {
|
||||
console.error("No runnable scenarios after env filtering")
|
||||
if (skippedByEnv.length > 0) {
|
||||
for (const skipped of skippedByEnv) {
|
||||
console.error(` - ${skipped.id}: missing ${skipped.missingEnv.join(", ")}`)
|
||||
}
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Create results directory with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const resultsBaseDir = path.join(__dirname, "results")
|
||||
@@ -406,11 +485,17 @@ async function main() {
|
||||
|
||||
// Models are now always explicit
|
||||
const resolvedModels = models
|
||||
const providersInRun = [...new Set(scenarios.map((scenario) => getScenarioProvider(scenario)))]
|
||||
|
||||
console.log(`Running ${scenarios.length} scenarios × ${models.length} models × ${trials} trials`)
|
||||
console.log(`Provider: ${DEFAULT_PROVIDER}`)
|
||||
console.log(`Providers: ${providersInRun.join(", ")}`)
|
||||
console.log(`Models: ${resolvedModels.join(", ")}`)
|
||||
console.log(`Scenarios: ${scenarios.map((s) => s.id).join(", ")}`)
|
||||
if (skippedByEnv.length > 0) {
|
||||
console.log(
|
||||
`Skipped by env: ${skippedByEnv.map((skipped) => `${skipped.id} (missing: ${skipped.missingEnv.join(", ")})`).join("; ")}`,
|
||||
)
|
||||
}
|
||||
console.log(`Results: ${resultsDir}`)
|
||||
console.log(`Parallel: ${parallel ? `yes (limit: ${parallelLimit})` : "no"}`)
|
||||
console.log("")
|
||||
@@ -420,7 +505,7 @@ async function main() {
|
||||
|
||||
// Build list of all scenario+model combinations
|
||||
interface TestJob {
|
||||
scenario: Scenario
|
||||
scenario: SmokeScenario
|
||||
modelId: string
|
||||
}
|
||||
const jobs: TestJob[] = []
|
||||
@@ -437,6 +522,26 @@ async function main() {
|
||||
const logDir = path.join(resultsDir, scenario.id, modelId)
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
|
||||
const authResult = ensureScenarioAuth(scenario, modelId)
|
||||
if (!authResult.ok) {
|
||||
const trialResults = Array.from({ length: trials }, () => ({
|
||||
passed: false,
|
||||
error: authResult.error || "Scenario authentication failed",
|
||||
durationMs: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}))
|
||||
return {
|
||||
scenarioId: scenario.id,
|
||||
scenarioName: scenario.name,
|
||||
model: modelId,
|
||||
modelId,
|
||||
trials: trialResults,
|
||||
metrics: metricsCalc.calculateTaskMetrics(trialResults.map((t) => t.passed)),
|
||||
status: metricsCalc.getTaskStatus(trialResults.map((t) => t.passed)),
|
||||
}
|
||||
}
|
||||
|
||||
const trialResults: TrialResult[] = []
|
||||
const trialWorkdirs: string[] = []
|
||||
|
||||
@@ -504,58 +609,23 @@ async function main() {
|
||||
// Sequential execution
|
||||
for (const job of jobs) {
|
||||
console.log(`\n[${job.scenario.id}] ${job.scenario.name} (${job.modelId})`)
|
||||
const logDir = path.join(resultsDir, job.scenario.id, job.modelId)
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
|
||||
const trialResults: TrialResult[] = []
|
||||
const trialWorkdirs: string[] = []
|
||||
|
||||
for (let t = 0; t < trials; t++) {
|
||||
const trialWorkdir = path.join(logDir, `workspace-trial-${t + 1}`)
|
||||
trialWorkdirs.push(trialWorkdir)
|
||||
process.stdout.write(` Trial ${t + 1}/${trials}... `)
|
||||
const result = await runTrial(job.scenario, job.modelId, trialWorkdir)
|
||||
trialResults.push(result)
|
||||
console.log(result.passed ? "✓ PASS" : `✗ FAIL: ${result.error}`)
|
||||
}
|
||||
|
||||
trialResults.forEach((result, t) => {
|
||||
const trialNum = t + 1
|
||||
const logContent =
|
||||
`# Trial ${trialNum}\n` +
|
||||
`Status: ${result.passed ? "PASS" : "FAIL"}\n` +
|
||||
`Duration: ${result.durationMs}ms\n` +
|
||||
(result.error ? `Error: ${result.error}\n` : "") +
|
||||
`\n## STDOUT\n${result.stdout || "(empty)"}\n` +
|
||||
`\n## STDERR\n${result.stderr || "(empty)"}\n`
|
||||
fs.writeFileSync(path.join(logDir, `trial-${trialNum}.log`), logContent)
|
||||
})
|
||||
|
||||
const trialBools = trialResults.map((t) => t.passed)
|
||||
const metrics = metricsCalc.calculateTaskMetrics(trialBools)
|
||||
const status = metricsCalc.getTaskStatus(trialBools)
|
||||
|
||||
results.push({
|
||||
scenarioId: job.scenario.id,
|
||||
scenarioName: job.scenario.name,
|
||||
model: job.modelId,
|
||||
modelId: job.modelId,
|
||||
trials: trialResults,
|
||||
metrics,
|
||||
status,
|
||||
const result = await runJob(job)
|
||||
result.trials.forEach((trial, index) => {
|
||||
console.log(` Trial ${index + 1}/${trials}... ${trial.passed ? "✓ PASS" : `✗ FAIL: ${trial.error}`}`)
|
||||
})
|
||||
results.push(result)
|
||||
|
||||
// Display pass@k where k = actual trials (pass@3 is meaningless with fewer trials)
|
||||
const passMetric = trials >= 3 ? metrics.passAt3 : metrics.passAt1
|
||||
const passMetric = trials >= 3 ? result.metrics.passAt3 : result.metrics.passAt1
|
||||
const passLabel = trials >= 3 ? "pass@3" : "pass@1"
|
||||
console.log(` Result: ${status.toUpperCase()} | ${passLabel}: ${(passMetric * 100).toFixed(0)}%`)
|
||||
console.log(` Result: ${result.status.toUpperCase()} | ${passLabel}: ${(passMetric * 100).toFixed(0)}%`)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate report
|
||||
const report: SmokeTestReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: DEFAULT_PROVIDER,
|
||||
provider: providersInRun.join(","),
|
||||
models: resolvedModels,
|
||||
scenarios: scenarios.map((s) => s.id),
|
||||
trialsPerTest: trials,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Edit file with gpt-oss via OpenAI-compatible",
|
||||
"description": "Reproduces openai-compatible gpt-oss file editing reliability when native tool calling is enabled",
|
||||
"prompt": "Edit the file config.txt and change the line 'debug = false' to 'debug = true'. Prefer direct file-edit tools instead of shell command workarounds.",
|
||||
"provider": "openai",
|
||||
"models": [
|
||||
"gpt-oss-120b"
|
||||
],
|
||||
"requiredEnv": [
|
||||
"OPENAI_COMPAT_API_KEY",
|
||||
"OPENAI_COMPAT_BASE_URL"
|
||||
],
|
||||
"auth": {
|
||||
"apiKeyEnv": "OPENAI_COMPAT_API_KEY",
|
||||
"baseUrlEnv": "OPENAI_COMPAT_BASE_URL",
|
||||
"modelId": "gpt-oss-120b"
|
||||
},
|
||||
"expectedFiles": [
|
||||
"config.txt"
|
||||
],
|
||||
"expectedContent": [
|
||||
{
|
||||
"file": "config.txt",
|
||||
"contains": "debug = true"
|
||||
}
|
||||
],
|
||||
"timeout": 180
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Application Configuration
|
||||
name = MyApp
|
||||
version = 1.0.0
|
||||
|
||||
# Debug settings
|
||||
debug = false
|
||||
log_level = info
|
||||
|
||||
# Server settings
|
||||
host = localhost
|
||||
port = 8080
|
||||
@@ -56,15 +56,10 @@
|
||||
- قم بتحديث الاختبارات الحالية إذا كانت تغييراتك تؤثر عليها
|
||||
- تضمين كل من اختبارات الوحدة واختبارات التكامل حيثما كان ذلك مناسبًا
|
||||
|
||||
4. **إدارة الإصدار مع Changesets**
|
||||
4. **ملاحظات الإصدار وسجل التغييرات**
|
||||
|
||||
- أنشئ changeset لأي تغييرات واجهة المستخدم باستخدام `npm run changeset`
|
||||
- اختر زيادة الإصدار المناسبة:
|
||||
- `major` للتغييرات الكبيرة (1.0.0 → 2.0.0)
|
||||
- `minor` للميزات الجديدة (1.0.0 → 1.1.0)
|
||||
- `patch` لإصلاحات الأخطاء (1.0.0 → 1.0.1)
|
||||
- اكتب رسائل changeset واضحة ووصفية تشرح التأثير
|
||||
- لا تتطلب التغييرات في الوثائق فقط changesets
|
||||
- لا يحتاج المساهمون إلى إنشاء ملفات changelog-entry ضمن PR.
|
||||
- يتولى فريق الصيانة إدارة إصدار النسخ وتنسيق سجل التغييرات أثناء عملية الإصدار.
|
||||
|
||||
5. **إرشادات الالتزام (Commit Guidelines)**
|
||||
|
||||
@@ -90,4 +85,4 @@
|
||||
|
||||
من خلال إرسال طلب سحب، فإنك توافق على أن مساهماتك سيتم ترخيصها بنفس ترخيص المشروع ([Apache 2.0](LICENSE)).
|
||||
|
||||
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
|
||||
تذكر: المساهمة في Cline لا تقتصر فقط على كتابة الكود - إنها تتعلق بأن تكون جزءًا من مجتمع يُشكل مستقبل التطوير بمساعدة الذكاء الاصطناعي. لنبنِ شيئًا رائعًا معًا! 🚀
|
||||
|
||||
+5
-17
@@ -163,27 +163,15 @@
|
||||
<details>
|
||||
<summary>إنشاء طلب سحب (Pull Request)</summary>
|
||||
|
||||
1. قبل إنشاء PR، قم بإنشاء إدخال للتغييرات:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
سيطلب منك تحديد:
|
||||
- نوع التغيير (رئيسي، ثانوي، إصلاح)
|
||||
- `رئيسي` → تغييرات غير متوافقة (1.0.0 → 2.0.0)
|
||||
- `ثانوي` → ميزات جديدة (1.0.0 → 1.1.0)
|
||||
- `إصلاح` → إصلاحات للأخطاء (1.0.0 → 1.0.1)
|
||||
- وصف التغييرات التي قمت بها
|
||||
1. قم بعمل commit لتغييراتك.
|
||||
|
||||
2. قم بحفظ التغييرات وملف `.changeset` الذي تم إنشاؤه
|
||||
2. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
|
||||
- تشغيل الاختبارات والفحوصات
|
||||
|
||||
3. ادفع فرعك وأنشئ PR على GitHub. سيقوم CI بـ:
|
||||
- تشغيل الاختبارات والفحوصات
|
||||
- سيقوم Changesetbot بإنشاء تعليق يوضح تأثير الإصدار
|
||||
- عند الدمج مع الفرع الرئيسي، سيقوم Changesetbot بإنشاء PR لحزم الإصدار
|
||||
- عند دمج PR لحزم الإصدار، سيتم نشر إصدار جديد
|
||||
3. يتولى فريق الصيانة إدارة إصدار النسخ وتنسيق سجل التغييرات أثناء عملية الإصدار.
|
||||
|
||||
</details>
|
||||
|
||||
## الرخصة
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
+4
-16
@@ -146,24 +146,12 @@ Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서
|
||||
<details>
|
||||
<summary>Pull Request 생성 방법</summary>
|
||||
|
||||
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
이후 프롬프트에서 다음 정보를 입력하세요:
|
||||
- 변경 유형 (major, minor, patch)
|
||||
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 변경 사항 설명 입력
|
||||
1. 변경 사항을 커밋하세요.
|
||||
|
||||
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
|
||||
|
||||
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
2. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
- 테스트 및 코드 검증 실행
|
||||
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
|
||||
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
|
||||
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
|
||||
|
||||
3. 버전 관리 및 변경 로그 정리는 릴리스 과정에서 메인테이너가 처리합니다.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -52,14 +52,9 @@
|
||||
- 若您的變更影響現有測試,請更新測試
|
||||
- 適當時包含單元測試與整合測試
|
||||
|
||||
4. **使用 Changesets 管理版本**
|
||||
- 使用 `npm run changeset` 為任何面向使用者的變更建立 changeset
|
||||
- 選擇適當的版本升級:
|
||||
- `major` 重大變更 (1.0.0 → 2.0.0)
|
||||
- `minor` 新功能 (1.0.0 → 1.1.0)
|
||||
- `patch` 錯誤修正 (1.0.0 → 1.0.1)
|
||||
- 撰寫清晰且描述性的 changeset 訊息,說明影響
|
||||
- 僅文件變更不需建立 changeset
|
||||
4. **版本與變更日誌說明**
|
||||
- 貢獻者不需要在 PR 中建立 changelog-entry 檔案。
|
||||
- 維護者會在發版流程中處理版本管理與變更日誌整理。
|
||||
|
||||
5. **提交指引**
|
||||
- 撰寫清晰且描述性的提交訊息
|
||||
|
||||
+4
-18
@@ -162,26 +162,12 @@ Cline 可以直接在您的編輯器中建立和編輯檔案,並顯示變更
|
||||
<details>
|
||||
<summary>建立 Pull Request</summary>
|
||||
|
||||
1. 在建立 PR 前,產生一個 changeset 項目:
|
||||
1. 提交您的變更。
|
||||
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
|
||||
這會提示您填寫:
|
||||
- 變更類型(major、minor、patch)
|
||||
- `major` → 重大變更(1.0.0 → 2.0.0)
|
||||
- `minor` → 新功能(1.0.0 → 1.1.0)
|
||||
- `patch` → 錯誤修正(1.0.0 → 1.0.1)
|
||||
- 您的變更說明
|
||||
|
||||
2. 提交您的變更和產生的 `.changeset` 檔案
|
||||
|
||||
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
2. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
- 執行測試和檢查
|
||||
- Changesetbot 會建立一個顯示版本影響的評論
|
||||
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
|
||||
- 當 Version Packages PR 合併時,就會發布新版本
|
||||
|
||||
3. 版本管理與變更日誌整理會由維護者在發版流程中處理。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Generated
+1074
-24
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.67.1",
|
||||
"version": "3.72.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -428,6 +428,7 @@
|
||||
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
|
||||
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
|
||||
"test:e2e:cli:tui": "cd tests/e2e/cli && tui-test",
|
||||
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
@@ -460,6 +461,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.14",
|
||||
"@bufbuild/buf": "^1.54.0",
|
||||
"@microsoft/tui-test": "0.0.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
|
||||
@@ -14,6 +14,7 @@ message HookInput {
|
||||
string task_id = 4;
|
||||
repeated string workspace_roots = 5;
|
||||
string user_id = 6;
|
||||
HookModelContext model = 7;
|
||||
oneof data {
|
||||
PreToolUseData pre_tool_use = 10;
|
||||
PostToolUseData post_tool_use = 11;
|
||||
@@ -23,9 +24,15 @@ message HookInput {
|
||||
TaskCancelData task_cancel = 15;
|
||||
TaskCompleteData task_complete = 16;
|
||||
PreCompactData pre_compact = 17;
|
||||
NotificationData notification = 18;
|
||||
}
|
||||
}
|
||||
|
||||
message HookModelContext {
|
||||
string provider = 1;
|
||||
string slug = 2;
|
||||
}
|
||||
|
||||
// Output message for all hooks
|
||||
message HookOutput {
|
||||
string context_modification = 1;
|
||||
@@ -54,6 +61,14 @@ message UserPromptSubmitData {
|
||||
repeated string attachments = 2;
|
||||
}
|
||||
|
||||
// Data for Notification hook
|
||||
message NotificationData {
|
||||
string event = 1;
|
||||
string source = 2;
|
||||
string message = 3;
|
||||
bool waiting_for_user_input = 4;
|
||||
}
|
||||
|
||||
// Data for TaskStart hook
|
||||
message TaskStartData {
|
||||
map<string, string> task_metadata = 1;
|
||||
|
||||
@@ -21,6 +21,8 @@ service ModelsService {
|
||||
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns recommended and free Cline models
|
||||
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
|
||||
// Refreshes and returns Cline provider models
|
||||
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns Hugging Face models
|
||||
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
|
||||
// Refreshes and returns OpenAI models
|
||||
@@ -283,6 +285,8 @@ message ModelsApiOptions {
|
||||
optional OcaModelInfo plan_mode_oca_model_info = 132;
|
||||
optional string plan_mode_aihubmix_model_id = 133;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
|
||||
optional string plan_mode_cline_model_id = 135;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
@@ -320,6 +324,8 @@ message ModelsApiOptions {
|
||||
optional OcaModelInfo act_mode_oca_model_info = 232;
|
||||
optional string act_mode_aihubmix_model_id = 233;
|
||||
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
|
||||
optional string act_mode_cline_model_id = 235;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
|
||||
}
|
||||
|
||||
// Request for updating API configuration (legacy - uses combined configuration)
|
||||
@@ -454,6 +460,7 @@ enum ApiProvider {
|
||||
AIHUBMIX = 38;
|
||||
NOUSRESEARCH = 39;
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
@@ -592,6 +599,7 @@ message ModelsApiConfiguration {
|
||||
optional string aihubmix_app_code = 84;
|
||||
optional string nous_research_api_key = 85;
|
||||
optional bool azure_identity = 86;
|
||||
optional string wandb_api_key = 87;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
@@ -634,6 +642,8 @@ message ModelsApiConfiguration {
|
||||
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 137;
|
||||
optional string plan_mode_nous_research_model_id = 138;
|
||||
optional string gemini_plan_mode_thinking_level = 139;
|
||||
optional string plan_mode_cline_model_id = 140;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
@@ -676,4 +686,6 @@ message ModelsApiConfiguration {
|
||||
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 237;
|
||||
optional string act_mode_nous_research_model_id = 238;
|
||||
optional string gemini_act_mode_thinking_level = 239;
|
||||
optional string act_mode_cline_model_id = 240;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ message Secrets {
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
optional string cline_api_key = 44;
|
||||
optional string wandb_api_key = 50;
|
||||
optional string openai_codex_oauth_credentials = 48;
|
||||
}
|
||||
|
||||
@@ -257,6 +258,7 @@ message Settings {
|
||||
optional PlanActMode mode = 147;
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional bool hooks_enabled = 152;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
@@ -278,6 +280,10 @@ message Settings {
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
optional string plan_mode_cline_model_id = 178;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
|
||||
optional string act_mode_cline_model_id = 180;
|
||||
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
}
|
||||
|
||||
@@ -382,7 +388,6 @@ message UpdateTaskSettingsRequest {
|
||||
message UpdateSettingsRequest {
|
||||
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
reserved 23; // was dictation_settings (dictation removed)
|
||||
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
reserved 38; // was skills_enabled (removed - now always enabled)
|
||||
|
||||
Metadata metadata = 1;
|
||||
@@ -406,6 +411,7 @@ message UpdateSettingsRequest {
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional bool hooks_enabled = 26;
|
||||
optional string vscode_terminal_execution_mode = 27;
|
||||
optional int32 max_consecutive_mistakes = 28;
|
||||
optional bool subagents_enabled = 29;
|
||||
|
||||
@@ -230,6 +230,40 @@ function snakeToCamel(str) {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a property key to the camelCase form parsed from proto field names.
|
||||
* Example: "openai-codex-oauth-credentials" -> "openaiCodexOauthCredentials".
|
||||
* We do this before looking up existing field numbers so regenerated proto fields
|
||||
* reuse their old numbers instead of being treated as new fields.
|
||||
*/
|
||||
function normalizePropertyNameForProtoLookup(fieldName) {
|
||||
return snakeToCamel(toProtoFieldName(fieldName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail fast when two distinct property keys collapse to the same normalized lookup key.
|
||||
* Collisions make field-number preservation ambiguous and can cause proto renumbering.
|
||||
*/
|
||||
function assertNoNormalizedFieldNameCollisions(fields) {
|
||||
const normalizedToOriginalNames = new Map()
|
||||
|
||||
for (const field of fields) {
|
||||
const normalizedName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const names = normalizedToOriginalNames.get(normalizedName) ?? new Set()
|
||||
names.add(field.name)
|
||||
normalizedToOriginalNames.set(normalizedName, names)
|
||||
}
|
||||
|
||||
for (const [normalizedName, names] of normalizedToOriginalNames) {
|
||||
if (names.size > 1) {
|
||||
const sortedNames = [...names].sort()
|
||||
throw new Error(
|
||||
`Field-name collision after proto normalization: ${sortedNames.join(", ")} all normalize to "${normalizedName}". Rename one field to keep proto field-number mapping unambiguous.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field numbers from an existing proto message definition
|
||||
* Returns a map of camelCase field names to their field numbers
|
||||
@@ -248,7 +282,8 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
const messageBody = match[1]
|
||||
|
||||
// Match field definitions: optional/required/repeated type name = number;
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
// Supports scalar/message types and map fields.
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
const matches = messageBody.matchAll(fieldRegex)
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
@@ -287,6 +322,8 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
const result = {}
|
||||
let nextNumber = startNumber
|
||||
|
||||
assertNoNormalizedFieldNameCollisions(fields)
|
||||
|
||||
// Find the highest existing number
|
||||
for (const num of Object.values(existingNumbers)) {
|
||||
if (num >= nextNumber) {
|
||||
@@ -296,8 +333,21 @@ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
|
||||
// Preserve existing assignments
|
||||
for (const field of fields) {
|
||||
if (existingNumbers[field.name] !== undefined) {
|
||||
result[field.name] = existingNumbers[field.name]
|
||||
// Normalize before lookup so keys like "openai-codex-oauth-credentials"
|
||||
// reuse existing proto numbers instead of being treated as new fields.
|
||||
const normalizedFieldName = normalizePropertyNameForProtoLookup(field.name)
|
||||
const normalizedFieldNumber = existingNumbers[normalizedFieldName]
|
||||
const rawFieldNumber = existingNumbers[field.name]
|
||||
|
||||
if (normalizedFieldNumber !== undefined && rawFieldNumber !== undefined && normalizedFieldNumber !== rawFieldNumber) {
|
||||
throw new Error(
|
||||
`Ambiguous proto field-number mapping for "${field.name}": normalized key "${normalizedFieldName}" -> ${normalizedFieldNumber}, raw key "${field.name}" -> ${rawFieldNumber}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const existingFieldNumber = normalizedFieldNumber ?? rawFieldNumber
|
||||
if (existingFieldNumber !== undefined) {
|
||||
result[field.name] = existingFieldNumber
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -12,7 +12,9 @@
|
||||
* - cd cli && npm run build:production
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { execSync } from "child_process"
|
||||
import dotenv from "dotenv"
|
||||
import fs from "fs"
|
||||
import { cp } from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -21,10 +23,18 @@ const BUILD_DIR = "dist-standalone"
|
||||
const CLI_DIR = "cli"
|
||||
const IS_VERBOSE = process.argv.includes("-v") || process.argv.includes("--verbose")
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
// Load .env from repo root
|
||||
dotenv.config({ path: path.join(rootDir, ".env") })
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Building Cline CLI NPM Package (TypeScript)\n")
|
||||
|
||||
await cleanBuildDir()
|
||||
setupEnvironmentVariables()
|
||||
|
||||
await buildTypeScriptCli()
|
||||
await copyCliDist()
|
||||
await copyPackageJson()
|
||||
@@ -36,6 +46,17 @@ async function main() {
|
||||
console.log(`To publish: cd ${BUILD_DIR} && npm publish`)
|
||||
}
|
||||
|
||||
function setupEnvironmentVariables() {
|
||||
// Use a different API key for CLI error capturing, to redirect CLI errors to a different project
|
||||
const cliErrorTrackingKey = process.env.CLI_ERROR_SERVICE_API_KEY
|
||||
if (cliErrorTrackingKey) {
|
||||
process.env.ERROR_SERVICE_API_KEY = cliErrorTrackingKey
|
||||
// If we're sending to a different project, enable exception autocapture
|
||||
process.env.ENABLE_ERROR_AUTOCAPTURE = "true"
|
||||
log_verbose("Set ERROR_SERVICE_API_KEY for build")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the build directory
|
||||
*/
|
||||
@@ -59,7 +80,7 @@ async function buildTypeScriptCli() {
|
||||
}
|
||||
|
||||
// Build production bundle
|
||||
execSync("npm run build:production", { stdio: "inherit", cwd: CLI_DIR })
|
||||
execSync("npm run build:production", { stdio: "inherit", cwd: CLI_DIR, env: process.env })
|
||||
console.log("✓ TypeScript CLI built")
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -42,6 +42,7 @@ import { TogetherHandler } from "./providers/together"
|
||||
import { VercelAIGatewayHandler } from "./providers/vercel-ai-gateway"
|
||||
import { VertexHandler } from "./providers/vertex"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { WandbHandler } from "./providers/wandb"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { ZAiHandler } from "./providers/zai"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
@@ -97,6 +98,7 @@ function createHandlerForProvider(
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler({
|
||||
@@ -253,7 +255,13 @@ function createHandlerForProvider(
|
||||
vsCodeLmModelSelector:
|
||||
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
|
||||
})
|
||||
case "cline":
|
||||
case "cline": {
|
||||
const clineModelId =
|
||||
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
|
||||
const clineModelInfo =
|
||||
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
|
||||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
|
||||
return new ClineHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
clineAccountId: options.clineAccountId,
|
||||
@@ -263,9 +271,11 @@ function createHandlerForProvider(
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
openRouterProviderSorting: options.openRouterProviderSorting,
|
||||
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
|
||||
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
|
||||
openRouterModelId: clineModelId,
|
||||
openRouterModelInfo: clineModelInfo,
|
||||
enableParallelToolCalling: options.enableParallelToolCalling,
|
||||
})
|
||||
}
|
||||
case "litellm":
|
||||
return new LiteLlmHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
@@ -443,6 +453,12 @@ function createHandlerForProvider(
|
||||
nousResearchApiKey: options.nousResearchApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeNousResearchModelId : options.actModeNousResearchModelId,
|
||||
})
|
||||
case "wandb":
|
||||
return new WandbHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
wandbApiKey: options.wandbApiKey,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
default:
|
||||
return new AnthropicHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import "should"
|
||||
import { anthropicModels } from "@shared/api"
|
||||
import { ANTHROPIC_FAST_MODE_BETA, AnthropicHandler } from "../anthropic"
|
||||
|
||||
describe("AnthropicHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: readonly unknown[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
describe("getModel", () => {
|
||||
it("should return the fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:fast"])
|
||||
})
|
||||
|
||||
it("should return the 1m fast mode model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-opus-4-6:1m:fast")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-6:1m:fast"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should route fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should include the 1m beta when routing 1m fast mode requests through the beta messages API", async () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-opus-4-6:1m:fast",
|
||||
})
|
||||
|
||||
const standardCreate = sinon.stub().resolves(createAsyncIterable())
|
||||
const betaCreate = sinon.stub().callsFake(function (this: { _client?: object }, _params: unknown) {
|
||||
should.exist(this._client)
|
||||
return Promise.resolve(createAsyncIterable())
|
||||
})
|
||||
|
||||
sinon.stub(handler as unknown as { ensureClient: () => unknown }, "ensureClient").returns({
|
||||
messages: {
|
||||
create: standardCreate,
|
||||
},
|
||||
beta: {
|
||||
messages: {
|
||||
_client: {},
|
||||
create: betaCreate,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system prompt", [{ role: "user", content: "Hello" }])) {
|
||||
}
|
||||
|
||||
sinon.assert.notCalled(standardCreate)
|
||||
sinon.assert.calledOnce(betaCreate)
|
||||
sinon.assert.calledWithMatch(betaCreate, {
|
||||
model: "claude-opus-4-6",
|
||||
betas: [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"],
|
||||
speed: "fast",
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1163,5 +1163,63 @@ describe("AwsBedrockHandler", () => {
|
||||
// Turn 4: toolResult
|
||||
formatted[3].content?.[0]?.toolResult?.toolUseId?.should.equal("call-2")
|
||||
})
|
||||
|
||||
it("should silently skip thinking blocks without warnings", () => {
|
||||
const h = new AwsBedrockHandler(mockOptions)
|
||||
const conversation: any[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Let me reason about this...", signature: "sig123" },
|
||||
{ type: "text", text: "Here is my response." },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Thanks!" }],
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = h["formatMessagesForConverseAPI"](conversation)
|
||||
|
||||
// Thinking block should be filtered out, only text remains
|
||||
formatted[0].content?.should.have.length(1)
|
||||
formatted[0].content?.[0]?.text?.should.equal("Here is my response.")
|
||||
formatted[1].content?.[0]?.text?.should.equal("Thanks!")
|
||||
})
|
||||
|
||||
it("should silently skip redacted_thinking blocks without warnings", () => {
|
||||
const h = new AwsBedrockHandler(mockOptions)
|
||||
const conversation: any[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "redacted_thinking", data: "encrypted_data_here" },
|
||||
{ type: "text", text: "Response after redacted thinking." },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = h["formatMessagesForConverseAPI"](conversation)
|
||||
|
||||
// Redacted thinking block should be filtered out
|
||||
formatted[0].content?.should.have.length(1)
|
||||
formatted[0].content?.[0]?.text?.should.equal("Response after redacted thinking.")
|
||||
})
|
||||
|
||||
it("should handle messages with only thinking blocks by producing empty content", () => {
|
||||
const h = new AwsBedrockHandler(mockOptions)
|
||||
const conversation: any[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Internal reasoning only", signature: "sig456" }],
|
||||
},
|
||||
]
|
||||
|
||||
const formatted = h["formatMessagesForConverseAPI"](conversation)
|
||||
|
||||
// All content filtered out
|
||||
formatted[0].content?.should.have.length(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "should"
|
||||
import { openRouterDefaultModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { ClineHandler } from "../cline"
|
||||
|
||||
describe("ClineHandler", () => {
|
||||
@@ -14,9 +16,14 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const createHandler = (options: ConstructorParameters<typeof ClineHandler>[0]) => {
|
||||
sinon.stub(ClineAccountService, "getInstance").returns({} as any)
|
||||
sinon.stub(AuthService, "getInstance").returns({} as any)
|
||||
return new ClineHandler(options)
|
||||
}
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = Object.create(ClineHandler.prototype) as ClineHandler
|
||||
;(handler as any).options = {}
|
||||
const handler = createHandler({})
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
@@ -56,4 +63,31 @@ describe("ClineHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should forward enableParallelToolCalling to OpenRouter payload", async () => {
|
||||
const handler = createHandler({ enableParallelToolCalling: true })
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: "openai/gpt-4o-mini",
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { GeminiHandler } from "../gemini"
|
||||
|
||||
describe("GeminiHandler", () => {
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
const createAsyncIterable = (data: any[] = []) => ({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield* data
|
||||
},
|
||||
})
|
||||
|
||||
it("caps maxOutputTokens to 8192 for Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-flash",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-1",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.have.property("maxOutputTokens", 8_192)
|
||||
})
|
||||
|
||||
it("does not set maxOutputTokens for non-Flash models", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
apiModelId: "gemini-2.5-pro",
|
||||
})
|
||||
|
||||
const generateContentStream = sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp-2",
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 20,
|
||||
cachedContentTokenCount: 0,
|
||||
thoughtsTokenCount: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
sinon.stub(handler as any, "ensureClient").returns({
|
||||
models: { generateContentStream },
|
||||
} as any)
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }] as any)) {
|
||||
// Consume stream to trigger request execution.
|
||||
}
|
||||
|
||||
const requestArgs = generateContentStream.firstCall.args[0] as Record<string, any>
|
||||
requestArgs.config.should.not.have.property("maxOutputTokens")
|
||||
})
|
||||
|
||||
it("should emit unique tool call IDs when multiple function calls share one responseId", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: "resp_1",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: "read_file",
|
||||
args: { path: ".gitattributes" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(2)
|
||||
chunks[0].tool_call.function.id.should.equal("resp_1-tool-0")
|
||||
chunks[1].tool_call.function.id.should.equal("resp_1-tool-1")
|
||||
chunks[0].tool_call.call_id.should.equal(chunks[0].tool_call.function.id)
|
||||
chunks[1].tool_call.call_id.should.equal(chunks[1].tool_call.function.id)
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
JSON.parse(chunks[1].tool_call.function.arguments).path.should.equal(".gitattributes")
|
||||
})
|
||||
|
||||
it("should preserve Gemini-provided functionCall.id when present", async () => {
|
||||
const handler = new GeminiHandler({
|
||||
geminiApiKey: "test-api-key",
|
||||
})
|
||||
|
||||
const fakeClient = {
|
||||
models: {
|
||||
generateContentStream: sinon.stub().resolves(
|
||||
createAsyncIterable([
|
||||
{
|
||||
responseId: "resp_2",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: "call_alpha",
|
||||
name: "read_file",
|
||||
args: { path: ".nvmrc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
|
||||
const tools = [{ name: "read_file", description: "read file", parameters: { type: "OBJECT" } }] as any
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
if (chunk.type === "tool_calls") {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
chunks.should.have.length(1)
|
||||
chunks[0].tool_call.function.id.should.equal("call_alpha")
|
||||
chunks[0].tool_call.call_id.should.equal("call_alpha")
|
||||
JSON.parse(chunks[0].tool_call.function.arguments).path.should.equal(".nvmrc")
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const tools = [{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } }] as any
|
||||
|
||||
it("should handle usage-only chunks when delta is missing", async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
@@ -57,4 +59,58 @@ describe("OpenRouterHandler", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
type ParallelToolCallsTestCase = {
|
||||
modelId: string
|
||||
enableParallelToolCalling: boolean
|
||||
expectedParallelToolCalls: boolean
|
||||
}
|
||||
|
||||
const parallelToolCallsTestCases: ParallelToolCallsTestCase[] = [
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
{
|
||||
modelId: "openai/gpt-4o-mini",
|
||||
enableParallelToolCalling: false,
|
||||
expectedParallelToolCalls: false,
|
||||
},
|
||||
{
|
||||
modelId: "google/gemini-3-flash-preview",
|
||||
enableParallelToolCalling: true,
|
||||
expectedParallelToolCalls: true,
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of parallelToolCallsTestCases) {
|
||||
const settingLabel = testCase.enableParallelToolCalling ? "enabled" : "disabled"
|
||||
it(`should set parallel_tool_calls=${testCase.expectedParallelToolCalls} for ${testCase.modelId} when setting is ${settingLabel}`, async () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-api-key",
|
||||
enableParallelToolCalling: testCase.enableParallelToolCalling,
|
||||
})
|
||||
const createStub = sinon.stub().resolves(createAsyncIterable([]))
|
||||
const fakeClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: createStub,
|
||||
},
|
||||
},
|
||||
}
|
||||
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
|
||||
sinon.stub(handler, "getModel").returns({
|
||||
id: testCase.modelId,
|
||||
info: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }], tools)) {
|
||||
// drain stream
|
||||
}
|
||||
|
||||
const payload = createStub.firstCall.args[0]
|
||||
payload.parallel_tool_calls.should.equal(testCase.expectedParallelToolCalls)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type {
|
||||
MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming,
|
||||
BetaRawMessageStreamEvent,
|
||||
} from "@anthropic-ai/sdk/resources/beta/messages/messages"
|
||||
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
|
||||
import type { MessageCreateParamsStreaming as AnthropicMessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages/messages"
|
||||
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
|
||||
import { AnthropicModelId, anthropicDefaultModelId, anthropicModels, CLAUDE_SONNET_1M_SUFFIX, ModelInfo } from "@shared/api"
|
||||
import {
|
||||
ANTHROPIC_FAST_MODE_SUFFIX,
|
||||
AnthropicModelId,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
} from "@shared/api"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
@@ -10,6 +22,8 @@ import { withRetry } from "../retry"
|
||||
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export const ANTHROPIC_FAST_MODE_BETA = "fast-mode-2026-02-01"
|
||||
|
||||
interface AnthropicHandlerOptions extends CommonApiHandlerOptions {
|
||||
apiKey?: string
|
||||
anthropicBaseUrl?: string
|
||||
@@ -49,10 +63,30 @@ export class AnthropicHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent> | AsyncIterable<BetaRawMessageStreamEvent>
|
||||
|
||||
const modelId = model.id.endsWith(CLAUDE_SONNET_1M_SUFFIX) ? model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length) : model.id
|
||||
const enable1mContextWindow = model.id.endsWith(CLAUDE_SONNET_1M_SUFFIX)
|
||||
const useFastMode = model.id.endsWith(ANTHROPIC_FAST_MODE_SUFFIX)
|
||||
const baseModelId = useFastMode ? model.id.slice(0, -ANTHROPIC_FAST_MODE_SUFFIX.length) : model.id
|
||||
const modelId = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
|
||||
? baseModelId.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
|
||||
: baseModelId
|
||||
const enable1mContextWindow = baseModelId.endsWith(CLAUDE_SONNET_1M_SUFFIX)
|
||||
const fastModeBetas = enable1mContextWindow
|
||||
? [ANTHROPIC_FAST_MODE_BETA, "context-1m-2025-08-07"]
|
||||
: [ANTHROPIC_FAST_MODE_BETA]
|
||||
const createFastModeMessage = (
|
||||
body: AnthropicMessageCreateParamsStreaming,
|
||||
): Promise<AsyncIterable<BetaRawMessageStreamEvent>> => {
|
||||
return (
|
||||
client.beta.messages.create as unknown as (
|
||||
params: BetaMessageCreateParamsStreaming & { speed: "fast" },
|
||||
) => Promise<AsyncIterable<BetaRawMessageStreamEvent>>
|
||||
)({
|
||||
...body,
|
||||
betas: fastModeBetas,
|
||||
speed: "fast",
|
||||
})
|
||||
}
|
||||
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
|
||||
@@ -62,48 +96,50 @@ export class AnthropicHandler implements ApiHandler {
|
||||
|
||||
if (model.info.supportsPromptCache) {
|
||||
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
|
||||
const requestBody: AnthropicMessageCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
// "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: anthropicMessages,
|
||||
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
stream: true,
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
// tool_choice options:
|
||||
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
|
||||
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
|
||||
// - any: tells Claude that it must use one of the provided tools, but doesn’t force a particular tool.
|
||||
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
|
||||
tool_choice: nativeToolsOn && !reasoningOn ? { type: "any" } : undefined,
|
||||
}
|
||||
|
||||
stream = await client.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
// "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: anthropicMessages,
|
||||
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
stream: true,
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
// tool_choice options:
|
||||
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
|
||||
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
|
||||
// - any: tells Claude that it must use one of the provided tools, but doesn’t force a particular tool.
|
||||
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
|
||||
tool_choice: nativeToolsOn && !reasoningOn ? { type: "any" } : undefined,
|
||||
},
|
||||
(() => {
|
||||
// 1m context window beta header
|
||||
if (enable1mContextWindow) {
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
)
|
||||
stream = useFastMode
|
||||
? await createFastModeMessage(requestBody)
|
||||
: await client.messages.create(
|
||||
requestBody,
|
||||
(() => {
|
||||
// 1m context window beta header
|
||||
if (enable1mContextWindow) {
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})(),
|
||||
)
|
||||
} else {
|
||||
stream = await client.messages.create({
|
||||
const requestBody: AnthropicMessageCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
@@ -112,7 +148,9 @@ export class AnthropicHandler implements ApiHandler {
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
})
|
||||
}
|
||||
|
||||
stream = useFastMode ? await createFastModeMessage(requestBody) : await client.messages.create(requestBody)
|
||||
}
|
||||
|
||||
const lastStartedToolCall = { id: "", name: "", arguments: "" }
|
||||
|
||||
@@ -65,14 +65,18 @@ interface ContentBlockStart {
|
||||
start?: {
|
||||
type?: string
|
||||
thinking?: string
|
||||
signature?: string
|
||||
toolUse?: ToolUseStart
|
||||
}
|
||||
contentBlock?: {
|
||||
type?: string
|
||||
thinking?: string
|
||||
signature?: string
|
||||
}
|
||||
type?: string
|
||||
thinking?: string
|
||||
// Redacted thinking block data
|
||||
data?: string
|
||||
}
|
||||
|
||||
// Define types for stream response deltas
|
||||
@@ -82,6 +86,7 @@ interface ContentBlockDelta {
|
||||
type?: string
|
||||
thinking?: string
|
||||
text?: string
|
||||
signature?: string
|
||||
reasoningContent?: {
|
||||
text?: string
|
||||
}
|
||||
@@ -102,7 +107,7 @@ interface ToolUseDelta {
|
||||
}
|
||||
|
||||
// Define types for supported content types
|
||||
type SupportedContentType = "text" | "image" | "thinking"
|
||||
type SupportedContentType = "text" | "image" | "thinking" | "redacted_thinking" | "document"
|
||||
|
||||
interface ContentItem {
|
||||
type: SupportedContentType
|
||||
@@ -628,6 +633,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: reasoningBlock.text,
|
||||
...(reasoningBlock.signature ? { signature: reasoningBlock.signature } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,17 +686,33 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
) {
|
||||
if (blockIndex !== undefined) {
|
||||
blockTypes.set(blockIndex, "reasoning")
|
||||
// Capture signature if provided at block start
|
||||
const signature = blockStart.start?.signature || blockStart.contentBlock?.signature || undefined
|
||||
// Initialize content if provided
|
||||
const initialContent =
|
||||
blockStart.start?.thinking || blockStart.contentBlock?.thinking || blockStart.thinking || ""
|
||||
if (initialContent) {
|
||||
if (initialContent || signature) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: initialContent,
|
||||
reasoning: initialContent || "",
|
||||
...(signature ? { signature } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redacted thinking blocks
|
||||
if (
|
||||
blockStart.start?.type === "redacted_thinking" ||
|
||||
blockStart.contentBlock?.type === "redacted_thinking" ||
|
||||
blockStart.type === "redacted_thinking"
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
...(blockStart.data ? { redacted_data: blockStart.data } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content block delta - accumulate content by block index
|
||||
@@ -707,8 +729,16 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
const blockType = blockTypes.get(blockIndex)
|
||||
const delta = chunk.contentBlockDelta.delta as ContentBlockDelta["delta"]
|
||||
|
||||
// Handle signature delta - used to send thinking block signatures
|
||||
if (delta?.type === "signature_delta" && delta?.signature) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "", // reasoning text already sent via thinking_delta
|
||||
signature: delta.signature,
|
||||
}
|
||||
}
|
||||
// Handle thinking delta (Anthropic SDK format)
|
||||
if (delta?.type === "thinking_delta" || delta?.thinking) {
|
||||
else if (delta?.type === "thinking_delta" || delta?.thinking) {
|
||||
const thinkingContent = delta.thinking || delta.text || ""
|
||||
if (thinkingContent) {
|
||||
yield {
|
||||
@@ -959,6 +989,12 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip thinking blocks - Bedrock Converse API handles thinking via
|
||||
// the thinking config parameter, not by replaying blocks in history
|
||||
if (item.type === "thinking" || item.type === "redacted_thinking") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (item.type === "tool_result") {
|
||||
const content = (() => {
|
||||
if (typeof item.content === "string") {
|
||||
|
||||
@@ -28,6 +28,7 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterModelInfo?: ModelInfo
|
||||
clineAccountId?: string
|
||||
clineApiKey?: string
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
|
||||
@@ -121,6 +122,7 @@ export class ClineHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ThinkingLevel,
|
||||
} from "@google/genai"
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { GEMINI_FLASH_MAX_OUTPUT_TOKENS, isGeminiFlashModel } from "@utils/model-utils"
|
||||
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
@@ -45,6 +46,18 @@ function mapReasoningEffortToGeminiThinkingLevel(effort: string): ThinkingLevel
|
||||
}
|
||||
}
|
||||
|
||||
function getGeminiMaxOutputTokens(modelId: string, modelMaxTokens?: number): number | undefined {
|
||||
if (!isGeminiFlashModel(modelId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (modelMaxTokens && modelMaxTokens > 0) {
|
||||
return Math.min(modelMaxTokens, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
}
|
||||
|
||||
return GEMINI_FLASH_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Google's Gemini API with optimized caching strategy and accurate cost accounting.
|
||||
*
|
||||
@@ -136,6 +149,9 @@ export class GeminiHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
// Gemini may emit multiple function calls under the same responseId and without functionCall.id.
|
||||
// Track a local sequence so each emitted tool call has a stable unique ID.
|
||||
const responseToolCallCount = new Map<string, number>()
|
||||
|
||||
// Configure thinking budget/level if supported
|
||||
const _thinkingBudget = this.options.thinkingBudgetTokens ?? 0
|
||||
@@ -152,6 +168,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
// Set up base generation config
|
||||
const maxOutputTokens = getGeminiMaxOutputTokens(modelId, info.maxTokens)
|
||||
const requestConfig: GenerateContentConfig = {
|
||||
// Add base URL if configured
|
||||
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
|
||||
@@ -159,6 +176,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
// Set temperature (default to 0)
|
||||
// Gemini 3 recommends 1.0
|
||||
temperature: info.temperature ?? 1,
|
||||
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
|
||||
}
|
||||
|
||||
// Add thinking config only if the model supports it
|
||||
@@ -210,6 +228,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
|
||||
let isFirstSdkChunk = true
|
||||
for await (const chunk of result) {
|
||||
const responseKey = chunk.responseId || "gemini-response"
|
||||
if (isFirstSdkChunk) {
|
||||
sdkFirstChunkTime = Date.now()
|
||||
ttftSdkMs = sdkFirstChunkTime - sdkCallStartTime
|
||||
@@ -238,12 +257,21 @@ export class GeminiHandler implements ApiHandler {
|
||||
const functionCall = part.functionCall
|
||||
const args = Object.entries(functionCall.args || {}).filter(([_key, val]) => !!val)
|
||||
if (functionCall.args && args.length > 0) {
|
||||
const existingId = functionCall.id?.trim()
|
||||
const toolCallId =
|
||||
existingId ??
|
||||
(() => {
|
||||
const sequenceNumber = responseToolCallCount.get(responseKey) ?? 0
|
||||
responseToolCallCount.set(responseKey, sequenceNumber + 1)
|
||||
return `${responseKey}-tool-${sequenceNumber}`
|
||||
})()
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
id: chunk.responseId,
|
||||
tool_call: {
|
||||
call_id: toolCallId,
|
||||
function: {
|
||||
id: chunk.responseId,
|
||||
id: toolCallId,
|
||||
name: functionCall.name,
|
||||
arguments: JSON.stringify(functionCall.args),
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OpenRouterHandlerOptions extends CommonApiHandlerOptions {
|
||||
openRouterProviderSorting?: string
|
||||
reasoningEffort?: string
|
||||
thinkingBudgetTokens?: number
|
||||
enableParallelToolCalling?: boolean
|
||||
}
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
@@ -68,6 +69,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
tools,
|
||||
this.options.enableParallelToolCalling,
|
||||
)
|
||||
|
||||
let didOutputUsage = false
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type ModelInfo, type WandbModelId, wandbDefaultModelId, wandbModels } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { createOpenAIClient } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface WandbHandlerOptions extends CommonApiHandlerOptions {
|
||||
wandbApiKey?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
export class WandbHandler implements ApiHandler {
|
||||
private client: OpenAI | undefined
|
||||
|
||||
constructor(private readonly options: WandbHandlerOptions) {}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.wandbApiKey) {
|
||||
throw new Error("W&B API key is required")
|
||||
}
|
||||
try {
|
||||
this.client = createOpenAIClient({
|
||||
baseURL: "https://api.inference.wandb.ai/v1",
|
||||
apiKey: this.options.wandbApiKey,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating W&B Inference client: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...getOpenAIToolParams(tools),
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta && "reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
// W&B Inference returns prompt_tokens_details.cached_tokens in the usage chunk,
|
||||
// but does not currently offer cache-aware billing (cached tokens are billed
|
||||
// at the same rate as regular input tokens). We report inputTokens as the full
|
||||
// prompt_tokens value and do not subtract cached tokens until W&B supports
|
||||
// cache-aware pricing. This may change in a future update.
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
|
||||
if (modelId !== undefined && modelId in wandbModels) {
|
||||
return { id: modelId, info: wandbModels[modelId as WandbModelId] }
|
||||
}
|
||||
return { id: wandbDefaultModelId, info: wandbModels[wandbDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import type { ModelInfo } from "@shared/api"
|
||||
import sinon from "sinon"
|
||||
import { createOpenRouterStream } from "../openrouter-stream"
|
||||
|
||||
describe("createOpenRouterStream", () => {
|
||||
const createAsyncIterable = () => ({
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
})
|
||||
|
||||
const createClient = () => {
|
||||
const create = sinon.stub().resolves(createAsyncIterable())
|
||||
return {
|
||||
client: {
|
||||
chat: {
|
||||
completions: {
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
create,
|
||||
}
|
||||
}
|
||||
|
||||
const createModelInfo = (maxTokens: number): ModelInfo => ({
|
||||
maxTokens,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
})
|
||||
|
||||
it("caps Gemini Flash OpenRouter requests to 8192 max_tokens", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 8_192)
|
||||
})
|
||||
|
||||
it("keeps lower Gemini Flash max_tokens values when already below 8192", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-flash",
|
||||
info: createModelInfo(4_096),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.have.property("max_tokens", 4_096)
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
info: createModelInfo(64_000),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
|
||||
it("does not send max_tokens for non-Flash Gemini models", async () => {
|
||||
const { client, create } = createClient()
|
||||
|
||||
await createOpenRouterStream(client as any, "system prompt", [{ role: "user", content: "hello" }] as any, {
|
||||
id: "google/gemini-2.5-pro",
|
||||
info: createModelInfo(65_536),
|
||||
})
|
||||
|
||||
const payload = create.firstCall.args[0] as Record<string, unknown>
|
||||
payload.should.not.have.property("max_tokens")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import "should"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../tool-call-processor"
|
||||
|
||||
describe("ToolCallProcessor", () => {
|
||||
it("should preserve tool call id/name for interleaved parallel deltas", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const firstChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_a",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "call_b",
|
||||
function: { name: "search_files" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const secondChunk = [
|
||||
{
|
||||
index: 1,
|
||||
function: { arguments: '{"path":"src"}' },
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"README.md"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
const firstResult = [...processor.processToolCallDeltas(firstChunk)]
|
||||
const secondResult = [...processor.processToolCallDeltas(secondChunk)]
|
||||
|
||||
firstResult.should.have.length(0)
|
||||
secondResult.should.have.length(2)
|
||||
// Intentionally reversed from the setup chunk: output follows incoming
|
||||
// argument-delta order, but reconstruction is correct regardless of arrival
|
||||
// order because id/name/arguments are matched by tool call index.
|
||||
const firstToolCall = secondResult[0]!.tool_call as any
|
||||
const secondToolCall = secondResult[1]!.tool_call as any
|
||||
firstToolCall.function.id.should.equal("call_b")
|
||||
firstToolCall.function.name.should.equal("search_files")
|
||||
firstToolCall.function.arguments.should.equal('{"path":"src"}')
|
||||
secondToolCall.function.id.should.equal("call_a")
|
||||
secondToolCall.function.name.should.equal("read_file")
|
||||
secondToolCall.function.arguments.should.equal('{"path":"README.md"}')
|
||||
})
|
||||
|
||||
it("should clear accumulated state on reset", () => {
|
||||
const processor = new ToolCallProcessor()
|
||||
|
||||
const setupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_reset",
|
||||
function: { name: "read_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const argsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"after-reset"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(setupChunk)].should.have.length(0)
|
||||
processor.reset()
|
||||
;[...processor.processToolCallDeltas(argsChunk)].should.have.length(0)
|
||||
|
||||
const newSetupChunk = [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_new",
|
||||
function: { name: "write_file" },
|
||||
},
|
||||
] as any
|
||||
|
||||
const newArgsChunk = [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '{"path":"file.txt"}' },
|
||||
},
|
||||
] as any
|
||||
|
||||
;[...processor.processToolCallDeltas(newSetupChunk)].should.have.length(0)
|
||||
;[...processor.processToolCallDeltas(newArgsChunk)].should.have.length(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenAIToolParams", () => {
|
||||
it("should include parallel_tool_calls when enabled", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, true) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(true)
|
||||
})
|
||||
|
||||
it("should include parallel_tool_calls=false when disabled by default", () => {
|
||||
const tools = [
|
||||
{ type: "function", function: { name: "read_file", description: "", parameters: { type: "object" } } },
|
||||
] as any
|
||||
const params = getOpenAIToolParams(tools, false) as any
|
||||
|
||||
params.parallel_tool_calls.should.equal(false)
|
||||
})
|
||||
|
||||
it("should not include parallel_tool_calls when tools are absent", () => {
|
||||
const params = getOpenAIToolParams(undefined, false) as any
|
||||
|
||||
params.should.not.have.property("parallel_tool_calls")
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
openRouterClaudeSonnet461mModelId,
|
||||
} from "@shared/api"
|
||||
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
|
||||
import {
|
||||
GEMINI_FLASH_MAX_OUTPUT_TOKENS,
|
||||
isGeminiFlashModel,
|
||||
shouldSkipReasoningForModel,
|
||||
supportsReasoningEffortForModel,
|
||||
} from "@utils/model-utils"
|
||||
import OpenAI from "openai"
|
||||
import { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { convertToOpenAiMessages, sanitizeGeminiMessages } from "./openai-format"
|
||||
@@ -25,6 +30,7 @@ export async function createOpenRouterStream(
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
tools?: Array<ChatCompletionTool>,
|
||||
enableParallelToolCalling?: boolean,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -116,38 +122,6 @@ export async function createOpenRouterStream(
|
||||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-opus-4.6":
|
||||
case "anthropic/claude-haiku-4.5":
|
||||
case "anthropic/claude-4.5-haiku":
|
||||
case "anthropic/claude-sonnet-4.6":
|
||||
case "anthropic/claude-4.6-sonnet":
|
||||
case "anthropic/claude-sonnet-4.5":
|
||||
case "anthropic/claude-4.5-sonnet":
|
||||
case "anthropic/claude-sonnet-4":
|
||||
case "anthropic/claude-opus-4.5":
|
||||
case "anthropic/claude-opus-4.1":
|
||||
case "anthropic/claude-opus-4":
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined
|
||||
if (
|
||||
@@ -212,10 +186,13 @@ export async function createOpenRouterStream(
|
||||
const includeReasoning = !shouldSkipReasoningForModel(model.id) && reasoningEffortValue !== "none"
|
||||
const reasoningPayload =
|
||||
reasoning ?? (reasoningEffortValue && reasoningEffortValue !== "none" ? { effort: reasoningEffortValue } : undefined)
|
||||
const maxTokens = isGeminiFlashModel(model.id)
|
||||
? Math.min(model.info.maxTokens || GEMINI_FLASH_MAX_OUTPUT_TOKENS, GEMINI_FLASH_MAX_OUTPUT_TOKENS)
|
||||
: undefined
|
||||
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
@@ -226,7 +203,7 @@ export async function createOpenRouterStream(
|
||||
...(openRouterProviderSorting && !providerPreferences ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
...(providerPreferences ? { provider: providerPreferences } : {}),
|
||||
...(isClaude1m ? { provider: { order: ["anthropic", "google-vertex/global"], allow_fallbacks: false } } : {}),
|
||||
...getOpenAIToolParams(tools),
|
||||
...getOpenAIToolParams(tools, !!enableParallelToolCalling),
|
||||
}
|
||||
|
||||
// @ts-expect-error-next-line
|
||||
|
||||
@@ -12,10 +12,10 @@ import type { ApiStreamToolCallsChunk } from "./stream"
|
||||
* and yields properly formatted tool call chunks when arguments are received.
|
||||
*/
|
||||
export class ToolCallProcessor {
|
||||
private lastToolCall: { id: string; name: string }
|
||||
private toolCallStateByIndex: Map<number, { id: string; name: string }>
|
||||
|
||||
constructor() {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,28 +30,32 @@ export class ToolCallProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
for (const [fallbackIndex, toolCallDelta] of toolCallDeltas.entries()) {
|
||||
// OpenAI-style streams include an index per tool call. Use iteration order as a fallback.
|
||||
const toolCallIndex = toolCallDelta.index ?? fallbackIndex
|
||||
const toolCallState = this.getOrCreateToolCallState(toolCallIndex)
|
||||
|
||||
// Accumulate the tool call ID if present
|
||||
if (toolCallDelta.id) {
|
||||
this.lastToolCall.id = toolCallDelta.id
|
||||
toolCallState.id = toolCallDelta.id
|
||||
}
|
||||
|
||||
// Accumulate the function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
Logger.debug(`[ToolCallProcessor] Native Tool Called: ${toolCallDelta.function.name}`)
|
||||
this.lastToolCall.name = toolCallDelta.function.name
|
||||
toolCallState.name = toolCallDelta.function.name
|
||||
}
|
||||
|
||||
// Only yield when we have all required fields: id, name, and arguments
|
||||
if (this.lastToolCall.id && this.lastToolCall.name && toolCallDelta.function?.arguments) {
|
||||
if (toolCallState.id && toolCallState.name && toolCallDelta.function?.arguments) {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
...toolCallDelta,
|
||||
function: {
|
||||
...toolCallDelta.function,
|
||||
id: this.lastToolCall.id,
|
||||
name: this.lastToolCall.name,
|
||||
id: toolCallState.id,
|
||||
name: toolCallState.name,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -59,29 +63,42 @@ export class ToolCallProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateToolCallState(index: number): { id: string; name: string } {
|
||||
const existingState = this.toolCallStateByIndex.get(index)
|
||||
if (existingState) {
|
||||
return existingState
|
||||
}
|
||||
|
||||
const initialState = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.set(index, initialState)
|
||||
return initialState
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the internal state. Call this when starting a new message.
|
||||
*/
|
||||
reset(): void {
|
||||
this.lastToolCall = { id: "", name: "" }
|
||||
this.toolCallStateByIndex.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current accumulated tool call state (useful for debugging).
|
||||
*/
|
||||
getState(): { id: string; name: string } {
|
||||
return { ...this.lastToolCall }
|
||||
getState(): Record<number, { id: string; name: string }> {
|
||||
return Object.fromEntries(this.toolCallStateByIndex.entries())
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls: boolean = false) {
|
||||
return tools?.length
|
||||
? {
|
||||
tools,
|
||||
tool_choice: tools ? ("auto" as ChatCompletionToolChoiceOption) : undefined,
|
||||
parallel_tool_calls: enableParallelToolCalls ? true : false,
|
||||
}
|
||||
: {
|
||||
tools: undefined,
|
||||
}
|
||||
export function getOpenAIToolParams(tools?: OpenAITool[], enableParallelToolCalls = false) {
|
||||
if (!tools?.length) {
|
||||
return {
|
||||
tools: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
tool_choice: "auto" as ChatCompletionToolChoiceOption,
|
||||
parallel_tool_calls: enableParallelToolCalls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect } from "chai"
|
||||
import { checkContextWindowExceededError } from "../context-error-handling"
|
||||
|
||||
describe("checkContextWindowExceededError", () => {
|
||||
it("detects OpenRouter context errors using structured status", () => {
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
"This endpoint's maximum context length is 204800 tokens. However, you requested about 244027 tokens.",
|
||||
),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("detects OpenRouter JSON-encoded status + context length errors", () => {
|
||||
const error = new Error(
|
||||
'OpenRouter Mid-Stream Error: {"status":400,"message":"This endpoint\'s maximum context length is 200000 tokens"}',
|
||||
)
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(true)
|
||||
})
|
||||
|
||||
it("does not classify unrelated 400 errors as context window failures", () => {
|
||||
const error = new Error("OpenRouter API Error 400: Invalid API key")
|
||||
|
||||
expect(checkContextWindowExceededError(error)).to.equal(false)
|
||||
})
|
||||
})
|
||||
@@ -13,11 +13,15 @@ export function checkContextWindowExceededError(error: unknown): boolean {
|
||||
|
||||
function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
try {
|
||||
// OpenRouter errors can reach us in two shapes:
|
||||
// 1) Direct chunk.error path wrapped as Error with status/code attached.
|
||||
// 2) Mid-stream finish_reason="error" path where JSON is stringified into message.
|
||||
// So we check structured status first, then JSON-encoded status/code in message text.
|
||||
const status = error?.status ?? error?.code ?? error?.error?.status ?? error?.response?.status
|
||||
const message: string = String(error?.message || error?.error?.message || "")
|
||||
|
||||
// There seems to be an issue where the true status code is embedded only in the message itself
|
||||
const statusFromMessage = message.match(/"code":\s*(\d+)/)?.[1]
|
||||
// Handle JSON-encoded errors where status/code is embedded in the message string.
|
||||
const statusFromMessage = message.match(/"code":\s*(\d+)/)?.[1] ?? message.match(/"status":\s*(\d+)/)?.[1]
|
||||
const finalStatus = statusFromMessage || status
|
||||
|
||||
// Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length")
|
||||
|
||||
@@ -27,4 +27,32 @@ describe("parseYamlFrontmatter", () => {
|
||||
expect(result.body).to.equal(input)
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("rejects YAML custom tags (security: prevents unsafe deserialization)", () => {
|
||||
// !!js/function is the classic RCE vector in js-yaml v3.
|
||||
// With JSON_SCHEMA, any custom tag should be rejected.
|
||||
const input = `---\nfoo: !!js/function 'function(){ return 1 }'\n---\nBody`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.body).to.equal(input)
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("rejects !!python/object YAML tag", () => {
|
||||
const input = `---\nfoo: !!python/object:os.system 'echo pwned'\n---\nBody`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("parses JSON-compatible YAML values correctly", () => {
|
||||
const input = `---\ncount: 42\nenabled: true\ntags:\n - "a"\n - "b"\n---\nContent`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
|
||||
expect(result.body.trim()).to.equal("Content")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
try {
|
||||
const data = (yaml.load(yamlContent) as Record<string, unknown>) || {}
|
||||
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
|
||||
return { data, body, hadFrontmatter: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function createHook(
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(hooksDir, { recursive: true })
|
||||
|
||||
const hookPath = path.join(hooksDir, hookName)
|
||||
const hookFileName = process.platform === "win32" ? `${hookName}.ps1` : hookName
|
||||
const hookPath = path.join(hooksDir, hookFileName)
|
||||
|
||||
// Check if already exists
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DeleteHookRequest, DeleteHookResponse } from "@shared/proto/cline/file"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
|
||||
import { resolveHooksDirectory } from "../../hooks/utils"
|
||||
import { resolveExistingHookPath, resolveHooksDirectory } from "../../hooks/utils"
|
||||
import { Controller } from ".."
|
||||
import { refreshHooks } from "./refreshHooks"
|
||||
|
||||
@@ -15,14 +14,11 @@ export async function deleteHook(
|
||||
|
||||
// Determine hook path
|
||||
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
|
||||
|
||||
const hookPath = path.join(hooksDir, hookName)
|
||||
const hookPath = await resolveExistingHookPath(hooksDir, hookName)
|
||||
|
||||
// Verify hook exists before attempting deletion
|
||||
try {
|
||||
await fs.stat(hookPath)
|
||||
} catch {
|
||||
throw new Error(`Hook ${hookName} does not exist at ${hookPath}`)
|
||||
if (!hookPath) {
|
||||
throw new Error(`Hook ${hookName} does not exist in ${hooksDir}`)
|
||||
}
|
||||
|
||||
// Delete the hook file
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { VALID_HOOK_TYPES } from "../../hooks/utils"
|
||||
import { resolveExistingHookPath, VALID_HOOK_TYPES } from "../../hooks/utils"
|
||||
import { Controller } from ".."
|
||||
|
||||
export async function refreshHooks(
|
||||
@@ -17,20 +17,15 @@ export async function refreshHooks(
|
||||
// Collect global hooks
|
||||
const globalHooks: HookInfo[] = []
|
||||
for (const hookName of VALID_HOOK_TYPES) {
|
||||
const hookPath = path.join(globalHooksDir, hookName)
|
||||
try {
|
||||
const stat = await fs.stat(hookPath)
|
||||
if (stat.isFile()) {
|
||||
globalHooks.push(
|
||||
HookInfo.create({
|
||||
name: hookName,
|
||||
enabled: await isExecutable(hookPath),
|
||||
absolutePath: hookPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, skip
|
||||
const hookPath = await resolveExistingHookPath(globalHooksDir, hookName)
|
||||
if (hookPath) {
|
||||
globalHooks.push(
|
||||
HookInfo.create({
|
||||
name: hookName,
|
||||
enabled: await isExecutable(hookPath),
|
||||
absolutePath: hookPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,20 +38,15 @@ export async function refreshHooks(
|
||||
const hooks: HookInfo[] = []
|
||||
|
||||
for (const hookName of VALID_HOOK_TYPES) {
|
||||
const hookPath = path.join(workspaceHooksDir, hookName)
|
||||
try {
|
||||
const stat = await fs.stat(hookPath)
|
||||
if (stat.isFile()) {
|
||||
hooks.push(
|
||||
HookInfo.create({
|
||||
name: hookName,
|
||||
enabled: await isExecutable(hookPath),
|
||||
absolutePath: hookPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, skip
|
||||
const hookPath = await resolveExistingHookPath(workspaceHooksDir, hookName)
|
||||
if (hookPath) {
|
||||
hooks.push(
|
||||
HookInfo.create({
|
||||
name: hookName,
|
||||
enabled: await isExecutable(hookPath),
|
||||
absolutePath: hookPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +71,8 @@ export async function refreshHooks(
|
||||
async function isExecutable(filePath: string): Promise<boolean> {
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, files are "enabled" if they exist
|
||||
// TODO(PR-9552 follow-up): Replace this temporary file-exists behavior
|
||||
// with JSON-backed cross-platform hook enablement state.
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user