mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
261
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d33a2fce | ||
|
|
263b58f8c3 | ||
|
|
629f20b52b | ||
|
|
b193a81ac9 | ||
|
|
92806c60ca | ||
|
|
3a05171e30 | ||
|
|
a6e315a4a6 | ||
|
|
2714f93b45 | ||
|
|
3abeb8a90b | ||
|
|
7830472017 | ||
|
|
83339c3c5a | ||
|
|
664daf6ded | ||
|
|
5b1f8850af | ||
|
|
c49d4121a3 | ||
|
|
7f495a5e99 | ||
|
|
1e88a708bd | ||
|
|
408be18be9 | ||
|
|
5a9c637e32 | ||
|
|
8152572641 | ||
|
|
0736e12e32 | ||
|
|
690f80523f | ||
|
|
4fa1b8a291 | ||
|
|
ed685b28e0 | ||
|
|
38338310d7 | ||
|
|
1c4a7885e6 | ||
|
|
a0517db2fa | ||
|
|
38134ef967 | ||
|
|
8715cafce7 | ||
|
|
46ee8ea329 | ||
|
|
b7d9ea4500 | ||
|
|
5147abc75e | ||
|
|
9abd7ae8c3 | ||
|
|
bf83303bb7 | ||
|
|
bfe5bf841a | ||
|
|
eb362df3ba | ||
|
|
f735ddcb7a | ||
|
|
5b63d3e9c8 | ||
|
|
1ff6a54825 | ||
|
|
b1a3cb6cfc | ||
|
|
78f1736723 | ||
|
|
28a014c1c6 | ||
|
|
fed291e37a | ||
|
|
bb68351123 | ||
|
|
84cb15813a | ||
|
|
c3671de7de | ||
|
|
26f737913f | ||
|
|
50797dd82d | ||
|
|
923ee3e137 | ||
|
|
bbf5bb2302 | ||
|
|
0220b9a506 | ||
|
|
1b573275f8 | ||
|
|
d7d74e0b89 | ||
|
|
bebc581652 | ||
|
|
21ebee3638 | ||
|
|
7d78c5f074 | ||
|
|
352a23a6da | ||
|
|
176662ee20 | ||
|
|
309ad9da72 | ||
|
|
16a6926985 | ||
|
|
d6db723c9d | ||
|
|
ce1fc20a9f | ||
|
|
b780a5ec00 | ||
|
|
285cd6d54c | ||
|
|
ff845539e4 | ||
|
|
05b0aa7dcb | ||
|
|
6cdd882acb | ||
|
|
d055b0941f | ||
|
|
21a0e4c4f1 | ||
|
|
54d022b536 | ||
|
|
a1709d37e5 | ||
|
|
b4157df509 | ||
|
|
60dee73f0f | ||
|
|
2d66bd475e | ||
|
|
a1374ae4a5 | ||
|
|
b8c62ecbae | ||
|
|
96b29d787e | ||
|
|
5b96beb583 | ||
|
|
f2af0d700c | ||
|
|
a9c47f88fd | ||
|
|
49884a1194 | ||
|
|
49da86f60c | ||
|
|
84477dd84f | ||
|
|
bd662d81f6 | ||
|
|
d8a3086eaa | ||
|
|
8ecd136e52 | ||
|
|
14a28b0559 | ||
|
|
c5378d1847 | ||
|
|
b5388e5c8d | ||
|
|
65b3977bc5 | ||
|
|
f4a46cf8e7 | ||
|
|
0b47033a0f | ||
|
|
c94ef4b750 | ||
|
|
3e855f7d3f | ||
|
|
2cd062ce68 | ||
|
|
be6999209e | ||
|
|
9a1e6121c7 | ||
|
|
4ae0ff4d5a | ||
|
|
18737f1448 | ||
|
|
b56ce72fc7 | ||
|
|
425182c7c0 | ||
|
|
30fe302a71 | ||
|
|
eadbc09ffd | ||
|
|
3178487bdd | ||
|
|
f58941c500 | ||
|
|
8da5ffa874 | ||
|
|
9ed95d1dc2 | ||
|
|
5fc7341312 | ||
|
|
d5b38f38cb | ||
|
|
386401b41f | ||
|
|
6471d2475a | ||
|
|
fada079558 | ||
|
|
31d50ff54a | ||
|
|
efebc6380b | ||
|
|
2acc25cf6e | ||
|
|
1ebe99c676 | ||
|
|
f5bbbd7f08 | ||
|
|
8e4a2b8a19 | ||
|
|
dfdeffb558 | ||
|
|
1d22a51e30 | ||
|
|
c827537386 | ||
|
|
f73121e369 | ||
|
|
52bbda183d | ||
|
|
80036cef44 | ||
|
|
b37f872a1f | ||
|
|
ebe2b6498b | ||
|
|
365250c785 | ||
|
|
1e208472da | ||
|
|
ee974ea863 | ||
|
|
a6416d02c8 | ||
|
|
55cfeaec65 | ||
|
|
acfe779b20 | ||
|
|
cff8c22f06 | ||
|
|
8f2be20858 | ||
|
|
f1fd189631 | ||
|
|
9b03cb878d | ||
|
|
3dc020185f | ||
|
|
4829f08b3f | ||
|
|
82d1846a45 | ||
|
|
8636272eb5 | ||
|
|
815b0346f6 | ||
|
|
84fe95de4d | ||
|
|
36be3333d4 | ||
|
|
96c0bd70a4 | ||
|
|
6f621e4bf2 | ||
|
|
d7ea201c25 | ||
|
|
e3b3e6b0ad | ||
|
|
53d0f50494 | ||
|
|
141a61fd45 | ||
|
|
7578a1de36 | ||
|
|
5aa60cf116 | ||
|
|
b62e572ea9 | ||
|
|
268462ddcf | ||
|
|
072e237d01 | ||
|
|
31cff1f75e | ||
|
|
9a981f81d7 | ||
|
|
d8b1ce54d4 | ||
|
|
6bc771ba9d | ||
|
|
87338cacd1 | ||
|
|
827a4a2616 | ||
|
|
13d6fe3876 | ||
|
|
82c3d8d771 | ||
|
|
ee0c63a216 | ||
|
|
11e668ca23 | ||
|
|
50a4bfb435 | ||
|
|
2c53d36e6d | ||
|
|
e4ab360c3c | ||
|
|
5363b2a6d2 | ||
|
|
51d3865ec5 | ||
|
|
2a070cddf5 | ||
|
|
401154a42e | ||
|
|
3b02d5162e | ||
|
|
710cceab90 | ||
|
|
a469377d57 | ||
|
|
1689abf479 | ||
|
|
abd84b80cb | ||
|
|
9d4aa03d4d | ||
|
|
91077fd49e | ||
|
|
6aa4c33918 | ||
|
|
aa8e11ad47 | ||
|
|
ca128cfd9f | ||
|
|
78b1f30133 | ||
|
|
1f31738b32 | ||
|
|
6eeee7c4e6 | ||
|
|
e88e0c4994 | ||
|
|
4258b338df | ||
|
|
7b24026291 | ||
|
|
9a6c972b02 | ||
|
|
fae824bff0 | ||
|
|
9011051f76 | ||
|
|
c4068823a4 | ||
|
|
323492108d | ||
|
|
58b41fbaf5 | ||
|
|
3df2d6c780 | ||
|
|
60b1ab4d57 | ||
|
|
0755b58da1 | ||
|
|
9c030a93b6 | ||
|
|
f9fcf7f5ce | ||
|
|
c4cf2743d6 | ||
|
|
1dbfb50038 | ||
|
|
9b548347ef | ||
|
|
2dc5791d46 | ||
|
|
35e3bb4787 | ||
|
|
205a539491 | ||
|
|
0175548dd8 | ||
|
|
6acc231da5 | ||
|
|
d2ac39455b | ||
|
|
87543e6f47 | ||
|
|
3757a8fd0d | ||
|
|
0e9a5c0fc1 | ||
|
|
8aba515d6b | ||
|
|
a2194f4908 | ||
|
|
f5d0b4fd49 | ||
|
|
61ea8688c6 | ||
|
|
94f5a47a59 | ||
|
|
864419d8b0 | ||
|
|
218db38544 | ||
|
|
a3b3295461 | ||
|
|
d09270940f | ||
|
|
23c9bf1c05 | ||
|
|
52531d935a | ||
|
|
19d4248381 | ||
|
|
b2b29678a2 | ||
|
|
ad30714608 | ||
|
|
9eebc44f24 | ||
|
|
6ddf48d227 | ||
|
|
ee59f81706 | ||
|
|
92f6e28f13 | ||
|
|
27a78f0248 | ||
|
|
2c4980f42a | ||
|
|
64c5e48edb | ||
|
|
3c23f80a94 | ||
|
|
4be362892f | ||
|
|
cdff084652 | ||
|
|
299a4a9520 | ||
|
|
c497698beb | ||
|
|
292133989b | ||
|
|
f5aa035a6a | ||
|
|
bbcae25542 | ||
|
|
9b914912ac | ||
|
|
8c369eeed9 | ||
|
|
85223a07cf | ||
|
|
eb2687677c | ||
|
|
6fe5acb2a4 | ||
|
|
99fb5101e5 | ||
|
|
58db9887ee | ||
|
|
b16a28cd6f | ||
|
|
114aa3f161 | ||
|
|
64743d2911 | ||
|
|
d7292993e9 | ||
|
|
8110cc46d8 | ||
|
|
a3cd39da14 | ||
|
|
a51b156383 | ||
|
|
7966bb9f21 | ||
|
|
d92e4797ed | ||
|
|
0de0c94555 | ||
|
|
94b82fca3c | ||
|
|
ba2f2284e8 | ||
|
|
b7f955c78b | ||
|
|
8433549327 | ||
|
|
860f544ab8 | ||
|
|
0761de984b |
@@ -41,11 +41,11 @@ fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
npm run install:all
|
||||
bun run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
npm run protos
|
||||
bun run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Bun (tooling) and Node (runtime)
|
||||
|
||||
This repo uses **bun** for package management and task running, and **Node** as
|
||||
the execution runtime. Both are correct at the same time; the distinction is the
|
||||
source of most confusion, so keep it straight before editing scripts, configs,
|
||||
docs, or comments.
|
||||
|
||||
## Use bun for tooling
|
||||
|
||||
- `bun install` (never `npm install` / `npm ci`)
|
||||
- `bun run <script>` (never `npm run <script>`)
|
||||
- `bunx <bin>` (never `npx <bin>`)
|
||||
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
|
||||
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
|
||||
- `bun run --parallel ...` for parallel tasks
|
||||
|
||||
The root `bun.lock` is the single lockfile for the whole workspace, including
|
||||
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
|
||||
lockfiles.
|
||||
|
||||
## Node is the runtime — do NOT rewrite these to bun
|
||||
|
||||
The build product runs on Node: the VS Code extension host loads
|
||||
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
|
||||
Node process. The following are Node runtime/ABI references and are correct as-is:
|
||||
|
||||
| Reference | Why it is Node |
|
||||
|-----------|----------------|
|
||||
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
|
||||
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
|
||||
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
|
||||
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
|
||||
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
|
||||
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
|
||||
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
|
||||
|
||||
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
|
||||
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
|
||||
the runtime/ABI target, not tooling. If unsure, leave it.
|
||||
|
||||
## Tests: bun vs the VS Code host
|
||||
|
||||
A test file's runner is decided by its import:
|
||||
|
||||
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
|
||||
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
|
||||
discovers these by the `bun:test` import and runs one isolated bun process per
|
||||
file. `build-tests.js` excludes them from the integration compile so the
|
||||
`bun:test` builtin never reaches Node.
|
||||
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
|
||||
extension host (Node). These exercise the live `vscode` API and cannot run
|
||||
under bun.
|
||||
|
||||
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
|
||||
needs the real extension host.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`bun run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+93
-90
@@ -13,11 +13,56 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -28,7 +73,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
**Run `bun run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
@@ -48,93 +93,6 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
@@ -151,7 +109,7 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
|
||||
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
|
||||
|
||||
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
|
||||
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
|
||||
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
@@ -199,3 +157,48 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
npm run protos
|
||||
bun run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **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`.
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun 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`.
|
||||
2. **Generate**: `bun 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`.
|
||||
@@ -38,7 +38,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
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`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun 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.
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
@@ -50,21 +53,47 @@ jobs:
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the publish
|
||||
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
|
||||
# setup-bun does not provide a Node runtime, so keep setup-node here.
|
||||
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
|
||||
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally here (npm is available via setup-node). vsce is installed globally
|
||||
# too to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -82,7 +111,9 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
|
||||
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
|
||||
run: bun run publish:marketplace:nightly
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
@@ -27,6 +27,10 @@ permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
@@ -102,26 +106,61 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG'."
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
|
||||
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the
|
||||
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
|
||||
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
|
||||
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
|
||||
# ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm install` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally (npm is available via setup-node). vsce is installed globally too
|
||||
# to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -141,6 +180,60 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(
|
||||
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
|
||||
| head -n 1 || true
|
||||
)
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between the matching version heading and the next release heading.
|
||||
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
|
||||
$0 == "## [" version "]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { if (!found) exit 1 }
|
||||
' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -164,35 +257,23 @@ jobs:
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
# Required to generate the .vsix. --no-dependencies: the extension
|
||||
# is fully esbuild-bundled, and under the bun workspace the @cline/*
|
||||
# deps are symlinks pointing outside the package, so without this vsce
|
||||
# would walk them and pull the whole monorepo into the .vsix.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
# These scripts run under `node scripts/publish-marketplace.mjs`;
|
||||
# bun run just launches them. Node + npm (for `npx ovsx`) come from
|
||||
# setup-node above.
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
bun run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
bun run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
@@ -45,12 +45,16 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
@@ -84,26 +88,20 @@ jobs:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
id: bun-cache
|
||||
with:
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -124,22 +122,41 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before building/packaging the extension for E2E.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
# Force bash: the Windows runner defaults to pwsh, which can't parse this
|
||||
# POSIX test. Git Bash ships on GitHub's windows-latest images.
|
||||
shell: bash
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
|
||||
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
|
||||
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
|
||||
# .bin on PATH. No global install needed.
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -148,11 +165,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: npm run test:e2e:optimal
|
||||
run: bun run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -45,13 +45,16 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.nycrc*.json'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
@@ -60,9 +63,13 @@ jobs:
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/testing-platform/package.json'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
@@ -82,27 +89,38 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace (apps/vscode,
|
||||
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
|
||||
# so the previous per-package `npm ci` steps collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; their dist/
|
||||
# output must be built before the extension can type-check/compile.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
run: bun run ci:check-all
|
||||
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
@@ -123,30 +141,43 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling/testing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: The old `npm config set script-shell bash` step is intentionally
|
||||
# removed. Scripts are now launched with `bun run`, which uses Bun's own
|
||||
# built-in cross-platform shell rather than npm's configured script-shell,
|
||||
# so that npm-specific Windows workaround no longer applies. Bash-dependent
|
||||
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
|
||||
# invoked explicitly via `bash ...` from within the package scripts, and
|
||||
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
|
||||
# the workflow `run:` blocks below.
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
@@ -158,24 +189,51 @@ jobs:
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
run: bun run ci:build
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
# The vitest config sets passWithNoTests: true, so a broken glob/alias
|
||||
# would "pass" with zero tests. Capture output and assert a non-zero
|
||||
# test count to guard against silent skips.
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun run test:vitest 2>&1 | tee vitest-output.log
|
||||
# Strip ANSI color codes before matching — vitest colorizes the
|
||||
# "Tests N passed" summary, so the count is not adjacent to the
|
||||
# "Tests" label in the raw bytes.
|
||||
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
|
||||
echo "ERROR: vitest reported zero tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Unit Tests (bun) - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
|
||||
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
|
||||
# The runner exits non-zero on any failure and prints a final
|
||||
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
|
||||
# guard against an empty glob silently "passing".
|
||||
run: |
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
set -o pipefail
|
||||
bun run test:unit 2>&1 | tee unit-output.log
|
||||
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
|
||||
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Unit Tests - Non-Linux
|
||||
- name: Unit Tests (bun) - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
npm run test:unit
|
||||
bun run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
run: xvfb-run -a bun run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
@@ -183,7 +241,7 @@ jobs:
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "Running extension integration tests (attempt ${attempt}/3)"
|
||||
if npm run test:integration; then
|
||||
if bun run test:integration; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -201,7 +259,7 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
bun run test:coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -210,7 +268,6 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
@@ -224,39 +281,45 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install extension dependencies
|
||||
# Single root install resolves the whole bun workspace, including the
|
||||
# testing-platform package, so the separate per-package `npm ci` steps
|
||||
# (extension + webview-ui + testing-platform) collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling the standalone core.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
run: bun run download-ripgrep
|
||||
|
||||
- name: Compile Standalone
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
run: bun run compile-standalone
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -13,6 +13,9 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
@@ -81,3 +84,4 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
|
||||
+2
-1
@@ -7,4 +7,5 @@ fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
lint-staged
|
||||
cd apps/vscode && bunx lint-staged
|
||||
|
||||
|
||||
Vendored
+2
-5
@@ -126,10 +126,7 @@
|
||||
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"runtimeExecutable": "bun",
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"env": {
|
||||
@@ -183,7 +180,7 @@
|
||||
"name": "Open Storybook",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"storybook"
|
||||
|
||||
Vendored
+14
-1
@@ -22,11 +22,24 @@
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"biome.requireConfiguration": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
|
||||
Vendored
+32
-12
@@ -6,7 +6,7 @@
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "npm run compile-standalone",
|
||||
"command": "bun run compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "npm run protos",
|
||||
"command": "bun run protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -65,7 +65,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview",
|
||||
"command": "bun run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -86,7 +86,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview:test",
|
||||
"command": "bun run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run dev:webview",
|
||||
"command": "bun run dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -145,7 +145,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild",
|
||||
"command": "bun run watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -169,7 +169,8 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -184,7 +185,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild:test",
|
||||
"command": "bun run watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -208,7 +209,8 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -224,7 +226,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:tsc",
|
||||
"command": "bun run watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -242,7 +244,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch-tests",
|
||||
"command": "bun run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -282,7 +284,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run storybook",
|
||||
"command": "bun run storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -311,6 +313,24 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
|
||||
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
|
||||
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
|
||||
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
|
||||
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
|
||||
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
|
||||
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
|
||||
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
|
||||
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
|
||||
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
|
||||
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
|
||||
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
|
||||
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
|
||||
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
|
||||
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
|
||||
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
|
||||
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
|
||||
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
|
||||
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
|
||||
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
|
||||
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
|
||||
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
|
||||
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
|
||||
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
|
||||
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
|
||||
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
+14
-14
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
3. Install [bun](https://bun.com)
|
||||
4. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
cd apps/vscode && npm run install:all && cd ../..
|
||||
cd apps/vscode && bun run install:all && cd ../..
|
||||
cd sdk && bun run build && cd ..
|
||||
```
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
3. Testing
|
||||
- Run `cd apps/vscode && npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- Run `cd apps/vscode && bun run test` to run tests locally.
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
2. **Local Development**
|
||||
- cd into the vscode extension, `cd apps/vscode`
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run `bun run install:all` to install dependencies
|
||||
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `bun run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
2. **Code Quality**
|
||||
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- Run `bun run lint` to check code style
|
||||
- Run `bun run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
3. **Testing**
|
||||
|
||||
- Add tests for new features
|
||||
- Run `npm test` to ensure all tests pass
|
||||
- Run `bun test` to ensure all tests pass
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
bun run test:e2e # Build and run all E2E tests
|
||||
bun run e2e # Run tests without rebuilding
|
||||
bun run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
|
||||
- Added an option to open the subscription page from the ClinePass options
|
||||
- Added marketplace uninstall support and surfaced plugin-bundled skills
|
||||
- Require quoted prompts for one-shot mode
|
||||
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
|
||||
- Updated coupon code
|
||||
|
||||
## 3.0.30
|
||||
|
||||
- Added a token count to the status bar, shown alongside cost
|
||||
- Added organization-specific error messages
|
||||
- Added SAP AI Core provider support
|
||||
- Refreshed the model catalog with the latest provider models
|
||||
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
|
||||
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
|
||||
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
|
||||
- Threaded proxy/CA-aware networking into the inference path
|
||||
- Persisted Bedrock settings to providers.json
|
||||
- Normalized JSON-like tool inputs by schema for more reliable tool calls
|
||||
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
|
||||
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
- Added a prefilled MCP install wizard command for quicker MCP server setup
|
||||
- Improved error handling and messaging when plugin MCP OAuth authorization fails
|
||||
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
|
||||
@@ -163,6 +163,30 @@ cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
|
||||
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
|
||||
```
|
||||
|
||||
### MCP servers
|
||||
|
||||
Manage MCP servers with the interactive wizard:
|
||||
|
||||
```sh
|
||||
cline mcp
|
||||
cline config mcp
|
||||
```
|
||||
|
||||
Open the add-server wizard with the name, transport, and command or URL already filled in with `cline mcp install` (`cline mcp add` also works). Stdio servers use everything after `--` as the command and arguments:
|
||||
|
||||
```sh
|
||||
cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
||||
```
|
||||
|
||||
Remote HTTP and SSE servers take a name, transport, and URL. The wizard still asks for auth details before saving:
|
||||
|
||||
```sh
|
||||
cline mcp install ctx7 --transport http https://mcp.context7.com/mcp
|
||||
cline mcp install events --transport sse https://example.com/sse
|
||||
```
|
||||
|
||||
Because this command opens the wizard, it requires a TTY.
|
||||
|
||||
### Connectors
|
||||
|
||||
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ const result = await Bun.build({
|
||||
},
|
||||
env: "OTEL_*",
|
||||
banner:
|
||||
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
|
||||
});
|
||||
|
||||
if (result.logs.length > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.24",
|
||||
"version": "3.0.31",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -746,6 +746,27 @@ Break work into clear steps.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("routes mcp install and requires a TTY for the prefilled wizard", () => {
|
||||
const result = runCli(
|
||||
[
|
||||
"mcp",
|
||||
"install",
|
||||
"fs",
|
||||
"--",
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp",
|
||||
],
|
||||
{ env: createIsolatedEnv() },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(asText(result.stderr)).toContain(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) {
|
||||
return () => {};
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
process.env[name] = value;
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue(
|
||||
"WORKSPACE_ROOT",
|
||||
options.cwd ? resolve(options.cwd) : undefined,
|
||||
),
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
installMcpServer: vi.fn((options) => {
|
||||
const { name, transport, warnings } =
|
||||
actual.buildMcpInstallTransport(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
type: "stdio",
|
||||
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
|
||||
});
|
||||
});
|
||||
|
||||
it("builds remote wizard defaults and normalizes http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "docs",
|
||||
transport: "streamable-http",
|
||||
targetArgs: ["https://example.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds SSE wizard defaults", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
targetArgs: ["https://example.com/sse"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "events",
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing stdio command and invalid remote URL", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
}),
|
||||
).toThrow(/requires a command/);
|
||||
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["not-a-url"],
|
||||
}),
|
||||
).toThrow(/Invalid MCP server URL/);
|
||||
});
|
||||
|
||||
it("rejects remote URL schemes other than http and https", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["file:///etc/passwd"],
|
||||
}),
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("builds direct stdio installs without shell-joining args", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds direct remote installs with headers and placeholder warnings", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
headers: ["Authorization: Bearer <token>"],
|
||||
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Extra": "yes",
|
||||
},
|
||||
},
|
||||
warnings: [
|
||||
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: true,
|
||||
runWizard,
|
||||
io: { writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(runWizard).toHaveBeenCalledWith({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a TTY because it opens the wizard", async () => {
|
||||
const writeErr = vi.fn();
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: false,
|
||||
runWizard,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating wizard install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
isTty: false,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly with --yes without requiring a TTY", async () => {
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints direct install JSON with --yes --json", async () => {
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
targetArgs: ["node", "server.js"],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
|
||||
name: "fs",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
export function buildMcpInstallDefaults(options: {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
export function installMcpServerDirect(
|
||||
options: McpInstallOptions,
|
||||
): McpInstallDirectResult {
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
initialAction: "add",
|
||||
addDefaults: defaults,
|
||||
exitAfterInitialAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (options.yes) {
|
||||
const result = installMcpServerDirect(options);
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
|
||||
for (const warning of result.warnings) {
|
||||
options.io?.writeErr(warning);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
return await (options.runWizard ?? runPrefilledWizard)(defaults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+28
-1184
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
|
||||
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
|
||||
)
|
||||
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
|
||||
.option(
|
||||
@@ -116,7 +116,6 @@ export function createProgram(): Command {
|
||||
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
|
||||
writeErr: () => {},
|
||||
})
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.enablePositionalOptions()
|
||||
.argument(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject when the user already targeted an agent", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
|
||||
).not.toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases install and uninstall when agent options come before the subcommand", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
"owner/repo",
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
|
||||
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
|
||||
});
|
||||
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("ignores leading flags when detecting the subcommand", () => {
|
||||
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
// own agent. `use` is intentionally excluded: without --agent it prints the
|
||||
// generated prompt to stdout, whereas adding --agent would launch that agent
|
||||
// interactively instead — not what someone scoping to Cline would expect.
|
||||
const CLINE_SCOPED_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"install",
|
||||
"i",
|
||||
"update",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"uninstall",
|
||||
]);
|
||||
|
||||
const SKILLS_SUBCOMMAND_ALIASES = new Map([
|
||||
["install", "add"],
|
||||
["uninstall", "remove"],
|
||||
]);
|
||||
|
||||
function hasAgentFlag(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
|
||||
);
|
||||
}
|
||||
|
||||
function optionConsumesNextValue(arg: string): boolean {
|
||||
return arg === "-a" || arg === "--agent";
|
||||
}
|
||||
|
||||
function findSubcommandIndex(args: readonly string[]): number {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith("-")) {
|
||||
if (optionConsumesNextValue(arg)) {
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSubcommand(args: readonly string[]): string | undefined {
|
||||
const index = findSubcommandIndex(args);
|
||||
return index >= 0 ? args[index] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSkillsSubcommandAliases(args: string[]): void {
|
||||
const index = findSubcommandIndex(args);
|
||||
if (index < 0) return;
|
||||
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
|
||||
if (alias) {
|
||||
args[index] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument list passed to `npx`, injecting `--agent cline` for
|
||||
* install-style subcommands unless the user already targeted an agent.
|
||||
*/
|
||||
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
|
||||
const args = [...userArgs];
|
||||
const subcommand = findSubcommand(args);
|
||||
normalizeSkillsSubcommandAliases(args);
|
||||
if (
|
||||
subcommand &&
|
||||
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
|
||||
!hasAgentFlag(args)
|
||||
) {
|
||||
args.push("--agent", "cline");
|
||||
}
|
||||
return ["-y", SKILLS_PACKAGE, ...args];
|
||||
}
|
||||
|
||||
function resolveExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
}
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all arguments to the open skills CLI via `npx skills`.
|
||||
*
|
||||
* Returns the child process exit code, or 1 if npx is unavailable or fails to
|
||||
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
|
||||
* pass straight through to the user's terminal.
|
||||
*/
|
||||
export async function runSkillCommand(
|
||||
userArgs: readonly string[],
|
||||
io: SkillCommandIo,
|
||||
): Promise<number> {
|
||||
const args = buildSkillsArgs(userArgs);
|
||||
const isWindows = process.platform === "win32";
|
||||
const options: SpawnOptions = {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(isWindows ? { shell: true } : {}),
|
||||
};
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
const child = spawn("npx", args, options);
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleSigint = () => forward("SIGINT");
|
||||
const handleSigterm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", handleSigint);
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
};
|
||||
|
||||
child.once("error", (error: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (error.code === "ENOENT") {
|
||||
io.writeErr(
|
||||
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
|
||||
);
|
||||
} else {
|
||||
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
|
||||
}
|
||||
resolve(1);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
cleanup();
|
||||
resolve(resolveExitCode(code, signal));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,36 +1,2 @@
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
|
||||
+225
-35
@@ -114,6 +114,7 @@ const telemetryMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
@@ -179,6 +180,8 @@ vi.mock("./utils/feature-flags", () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
vi.mock("./runtime/prompt", () => ({
|
||||
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
|
||||
@@ -252,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
|
||||
providerSettingsMocks.getProviderSettings.mockReset();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
|
||||
providerSettingsMocks.saveProviderSettings.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
|
||||
kanbanMocks.launchKanban.mockReset();
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
@@ -401,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("does not load interactive runtime for single-prompt mode", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -411,9 +417,88 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a single bare positional prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or unquoted prompt: nonexistent-command",
|
||||
),
|
||||
);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Use "cline --help"'),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects multiple bare positional prompt tokens", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello", "world"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or unquoted prompt: hello world",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("runs quoted positional prompt text", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello world"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello world",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown root flags before loading runtime modules", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--made-up-flag"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("unknown option '--made-up-flag'"),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a worktree and runs prompt sessions from it", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -422,7 +507,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/cline-worktree",
|
||||
workspaceRoot: "/tmp/cline-worktree",
|
||||
@@ -666,7 +751,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("uses the bundled catalog path for single-prompt runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -857,6 +942,33 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: {
|
||||
accountId: "acct-startup",
|
||||
accessToken: "workos:token",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
};
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
).toHaveBeenCalledWith({ id: "acct-startup" });
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
|
||||
.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
@@ -874,6 +986,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"dashboard",
|
||||
"--config",
|
||||
"/tmp/cline-config",
|
||||
"--data-dir",
|
||||
".cline-dashboard-data",
|
||||
"--port",
|
||||
"9090",
|
||||
"--no-open",
|
||||
@@ -884,6 +1000,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configDir: "/tmp/cline-config",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
port: "9090",
|
||||
openBrowser: false,
|
||||
io: expect.any(Object),
|
||||
@@ -919,7 +1037,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("skips hub prewarm for yolo runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -928,11 +1046,29 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects yolo runs with a single bare prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown command or unquoted prompt: hello"),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "/team", "find", "the", "bug"];
|
||||
process.argv = ["bun", "src/index.ts", "/team find the bug"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -948,12 +1084,12 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows /team usage in single-prompt mode when no task is provided", async () => {
|
||||
it("rejects /team without quoted task text", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
const stdoutWrite = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "/team"];
|
||||
@@ -961,9 +1097,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockState.runAgentCalls).toBe(0);
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Usage: /team <task description>"),
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown command or unquoted prompt: /team"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -972,14 +1109,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -988,19 +1125,40 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves thinking disabled when --thinking is not provided", async () => {
|
||||
it("leaves thinking unset when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables thinking when --thinking none is explicitly provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
@@ -1014,14 +1172,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1044,14 +1202,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -1060,6 +1218,32 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5",
|
||||
reasoning: { enabled: false },
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning effort", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
@@ -1070,14 +1254,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
@@ -1091,13 +1275,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1113,13 +1297,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1135,13 +1319,19 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"agentic",
|
||||
"say hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1191,13 +1381,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: false,
|
||||
@@ -1236,7 +1426,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1244,7 +1434,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
@@ -1263,7 +1453,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1271,7 +1461,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
|
||||
+108
-20
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
import {
|
||||
configureSandboxEnvironment,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
isOAuthProvider,
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
@@ -111,6 +113,23 @@ export function resolveConfigDirArg(argv: string[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectOption(value: string, previous: string[] = []): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
|
||||
// Shells strip quote characters before argv reaches us, so a prompt that was
|
||||
// typed in quotes is only observable when it remains one argv token with spaces.
|
||||
function promptArgLooksQuoted(arg: string | undefined): boolean {
|
||||
return !!arg && /\s/.test(arg);
|
||||
}
|
||||
|
||||
function writePromptArgError(args: string[]): void {
|
||||
const renderedArgs = args.join(" ");
|
||||
writeErr(
|
||||
`Unknown command or unquoted prompt: ${renderedArgs}\nPrompt text must be passed as a single quoted argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
installStreamErrorGuards();
|
||||
autoUpdateOnStartup();
|
||||
@@ -139,7 +158,7 @@ export async function runCli(): Promise<void> {
|
||||
// Re-enable built-in help/version output for the routing program
|
||||
program.configureOutput({
|
||||
writeOut: (str: string) => process.stdout.write(str),
|
||||
writeErr: (str: string) => process.stderr.write(str),
|
||||
writeErr: () => {},
|
||||
});
|
||||
// Default action handles non-subcommand args (e.g. prompt text)
|
||||
program.action(() => {});
|
||||
@@ -315,6 +334,28 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const skillCmd = program
|
||||
.command("skill")
|
||||
.description("Manage Cline Skills via the open skills CLI (npx skills)")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.argument("[args...]", "arguments forwarded to the skills CLI")
|
||||
.addHelpText(
|
||||
"after",
|
||||
"\nForwards to the open skills CLI via npx. Examples:\n" +
|
||||
" cline skill add <owner/repo> Add a skill into Cline\n" +
|
||||
" cline skill install <owner/repo> Alias for add\n" +
|
||||
" cline skill list List installed skills\n" +
|
||||
" cline skill remove Remove installed skills\n" +
|
||||
" cline skill uninstall Alias for remove\n" +
|
||||
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
|
||||
"Run 'npx skills --help' for the full command reference.",
|
||||
)
|
||||
.action(async () => {
|
||||
const { runSkillCommand } = await import("./commands/skill");
|
||||
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
|
||||
});
|
||||
|
||||
const connectCmd = program
|
||||
.command("connect")
|
||||
.description("Connect to an external channel")
|
||||
@@ -356,7 +397,7 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
const mcpCmd = program
|
||||
.command("mcp")
|
||||
.description("Manage MCP servers")
|
||||
.action(async () => {
|
||||
@@ -368,6 +409,40 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
});
|
||||
const mcpInstallCmd = mcpCmd
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Open the MCP add wizard with server fields prefilled")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument(
|
||||
"[targetArgs...]",
|
||||
"URL for remote transports, or command and args after -- for stdio",
|
||||
)
|
||||
.option(
|
||||
"--transport <transport>",
|
||||
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
|
||||
)
|
||||
.option("--header <header>", "Remote MCP request header", collectOption, [])
|
||||
.option("--yes", "Install noninteractively without opening the wizard")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string, targetArgs: string[]) => {
|
||||
const opts = mcpInstallCmd.opts<{
|
||||
header?: string[];
|
||||
json?: boolean;
|
||||
transport?: string;
|
||||
yes?: boolean;
|
||||
}>();
|
||||
const { runMcpInstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpInstallCommand({
|
||||
name,
|
||||
headers: opts.header,
|
||||
targetArgs,
|
||||
transport: opts.transport,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
yes: opts.yes === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
const createDoctorRuntimeCommand = async () => {
|
||||
const { createDoctorCommand } = await import("./commands/doctor");
|
||||
@@ -548,7 +623,12 @@ export async function runCli(): Promise<void> {
|
||||
const dashboardCmd = program
|
||||
.command("dashboard")
|
||||
.description("Start the Cline Hub dashboard and open it in a browser")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Workspace root", process.cwd())
|
||||
.option(
|
||||
"--data-dir <dir>",
|
||||
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
|
||||
)
|
||||
.option("--host <host>", "Dashboard bind host")
|
||||
.option("--port <port>", "Dashboard HTTP/WebSocket port")
|
||||
.option("--public-url <url>", "Public dashboard URL")
|
||||
@@ -556,7 +636,9 @@ export async function runCli(): Promise<void> {
|
||||
.option("--no-open", "Start the dashboard without opening a browser")
|
||||
.action(async () => {
|
||||
const opts = dashboardCmd.opts<{
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -565,7 +647,9 @@ export async function runCli(): Promise<void> {
|
||||
}>();
|
||||
const { runDashboardCommand } = await import("./commands/dashboard");
|
||||
ctx.exitCode = await runDashboardCommand({
|
||||
configDir: opts.config,
|
||||
cwd: opts.cwd,
|
||||
dataDir: opts.dataDir,
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
publicUrl: opts.publicUrl,
|
||||
@@ -614,6 +698,7 @@ export async function runCli(): Promise<void> {
|
||||
if (err instanceof CommanderError) {
|
||||
if (err.exitCode !== 0) {
|
||||
writeErr(err.message);
|
||||
process.exitCode = err.exitCode;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
@@ -750,6 +835,13 @@ export async function runCli(): Promise<void> {
|
||||
if (args.hooksDir?.trim()) {
|
||||
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
|
||||
}
|
||||
if (args.prompt && !args.interactive) {
|
||||
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
|
||||
writePromptArgError(program.args);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentOutputMode(args.outputMode);
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove =
|
||||
@@ -855,6 +947,12 @@ export async function runCli(): Promise<void> {
|
||||
};
|
||||
registerDisposable(stopUserInstructionService);
|
||||
try {
|
||||
const persistedClineAccountId = providerSettingsManager
|
||||
.getProviderSettings("cline")
|
||||
?.auth?.accountId?.trim();
|
||||
if (persistedClineAccountId) {
|
||||
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
|
||||
}
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
@@ -927,19 +1025,12 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const persistedReasoning = selectedProviderSettings?.reasoning;
|
||||
const persistedReasoningEffort = persistedReasoning?.effort;
|
||||
const reasoningEffortFromSettings =
|
||||
persistedReasoning?.enabled === false
|
||||
? "none"
|
||||
: persistedReasoningEffort && persistedReasoningEffort !== "none"
|
||||
? persistedReasoningEffort
|
||||
: persistedReasoning?.enabled === true
|
||||
? "medium"
|
||||
: "none";
|
||||
const effectiveReasoningEffort = args.thinkingExplicitlySet
|
||||
? (args.reasoningEffort ?? "none")
|
||||
: (args.reasoningEffort ?? reasoningEffortFromSettings);
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -975,11 +1066,8 @@ export async function runCli(): Promise<void> {
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
verbose: args.verbose,
|
||||
thinking: effectiveReasoningEffort !== "none",
|
||||
reasoningEffort:
|
||||
effectiveReasoningEffort === "none"
|
||||
? undefined
|
||||
: effectiveReasoningEffort,
|
||||
thinking: resolvedReasoning.thinking,
|
||||
reasoningEffort: resolvedReasoning.reasoningEffort,
|
||||
outputMode: args.outputMode,
|
||||
mode: args.mode,
|
||||
logger: loggerAdapter.core,
|
||||
|
||||
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
|
||||
).resolves.toEqual({ approved: false, reason: "no" });
|
||||
});
|
||||
|
||||
it("approves stale required-approval requests after auto-approve is enabled", async () => {
|
||||
const controller = createInteractiveApprovalController(makeConfig(false));
|
||||
controller.tuiToolApprover.current = async () => ({
|
||||
approved: false,
|
||||
reason: "stale prompt",
|
||||
});
|
||||
|
||||
controller.setInteractiveAutoApprove(true);
|
||||
|
||||
await expect(
|
||||
controller.requestToolApproval(makeRequest({ autoApprove: false })),
|
||||
).resolves.toEqual({ approved: true });
|
||||
});
|
||||
|
||||
it("denies approval-required requests when no TUI approver is available", async () => {
|
||||
const controller = createInteractiveApprovalController(makeConfig(false));
|
||||
|
||||
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
|
||||
|
||||
expect(controller.autoApproveAllRef.current).toBe(true);
|
||||
expect(config.defaultToolAutoApprove).toBe(false);
|
||||
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
|
||||
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
|
||||
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
|
||||
import {
|
||||
applyInteractiveAutoApproveOverride,
|
||||
cloneToolPolicies,
|
||||
resolveInteractiveAutoApprovePolicy,
|
||||
} from "../tool-policies";
|
||||
|
||||
export interface InteractiveRuntimeRefs {
|
||||
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
|
||||
const requestToolApproval = async (
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => {
|
||||
if (request.policy?.autoApprove === true) {
|
||||
if (autoApproveAllRef.current) {
|
||||
return { approved: true };
|
||||
}
|
||||
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
|
||||
if (request.policy?.autoApprove === true) {
|
||||
return { approved: true };
|
||||
}
|
||||
if (refs.tuiToolApprover.current) {
|
||||
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy: (toolName: string) =>
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName,
|
||||
baselinePolicies: baselineToolPolicies,
|
||||
enabled: autoApproveAllRef.current,
|
||||
}),
|
||||
...refs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +152,10 @@ function deferred<T>() {
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
options: {
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
@@ -164,6 +167,8 @@ function makeRuntime(
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
resolveToolPolicy:
|
||||
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
const startInput = manager.start.mock.calls[0]?.[0] as
|
||||
| { config?: Config }
|
||||
| undefined;
|
||||
const beforeTool = startInput?.config?.hooks?.beforeTool;
|
||||
expect(beforeTool).toBeTypeOf("function");
|
||||
|
||||
const result = await beforeTool?.({
|
||||
snapshot: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
status: "running",
|
||||
iteration: 1,
|
||||
messages: [],
|
||||
pendingToolCalls: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: "echo",
|
||||
description: "",
|
||||
inputSchema: {},
|
||||
execute: async () => "ok",
|
||||
},
|
||||
toolCall: {
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "echo",
|
||||
input: { text: "original" },
|
||||
},
|
||||
input: { text: "original" },
|
||||
});
|
||||
|
||||
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
input: { text: "updated" },
|
||||
policy: { autoApprove: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
) => NonNullable<Config["toolPolicies"]>[string];
|
||||
|
||||
function withInteractiveApprovalPolicyHook(
|
||||
hooks: AgentHooks | undefined,
|
||||
resolveToolPolicy: ToolPolicyResolver,
|
||||
): AgentHooks {
|
||||
return {
|
||||
...hooks,
|
||||
beforeTool: async (ctx) => {
|
||||
const result = await hooks?.beforeTool?.(ctx);
|
||||
if (result?.stop || result?.skip) {
|
||||
return result;
|
||||
}
|
||||
const policy = resolveToolPolicy(ctx.toolCall.toolName);
|
||||
return {
|
||||
...result,
|
||||
policy: {
|
||||
...result?.policy,
|
||||
autoApprove: policy.autoApprove,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveSessionRuntime(input: {
|
||||
config: Config;
|
||||
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
requestToolApproval: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult>;
|
||||
resolveToolPolicy: ToolPolicyResolver;
|
||||
askQuestionRef: AskQuestionRef;
|
||||
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (!runtimeHooks) {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const hooks = withInteractiveApprovalPolicyHook(
|
||||
runtimeHooks.hooks,
|
||||
input.resolveToolPolicy,
|
||||
);
|
||||
return buildInteractiveSessionConfig({
|
||||
config: input.config,
|
||||
chatCommandState: input.chatCommandState,
|
||||
runtimeHooks,
|
||||
runtimeHooks: { hooks },
|
||||
onTeamEvent: input.onTeamEvent,
|
||||
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,44 @@ const outputMocks = vi.hoisted(() => ({
|
||||
c: { dim: "", reset: "" },
|
||||
}));
|
||||
|
||||
const sessionEventsMocks = vi.hoisted(() => ({
|
||||
listener: undefined as ((event: unknown) => void) | undefined,
|
||||
subscribeToAgentEvents: vi.fn(
|
||||
(_: unknown, listener: (event: unknown) => void) => {
|
||||
sessionEventsMocks.listener = listener;
|
||||
return () => {};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLI_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -77,7 +114,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -101,6 +138,9 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -511,6 +551,39 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith("Missing API key");
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
|
||||
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
error.name = "ClineNotSubscribedError";
|
||||
sessionManagerMocks.start.mockRejectedValue(error);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits JSON error lines for non-completed results", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -576,6 +649,126 @@ describe("runAgent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy for failed results", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass subscription errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -837,4 +1030,121 @@ describe("runAgent", () => {
|
||||
expect.stringContaining("est. cost"),
|
||||
);
|
||||
});
|
||||
|
||||
it("zeros Cline free model costs in JSON results and agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "completed text",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
provider: "cline",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
aggregateUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
const { handleEvent } = await import("../utils/events");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
outputMode: "json",
|
||||
providerId: "cline",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const runResult = outputMocks.emitJsonLine.mock.calls.find(
|
||||
([, payload]) =>
|
||||
(payload as { type?: string } | undefined)?.type === "run_result",
|
||||
)?.[1] as
|
||||
| {
|
||||
usage?: { totalCost?: number };
|
||||
aggregateUsage?: { totalCost?: number };
|
||||
}
|
||||
| undefined;
|
||||
expect(runResult?.usage?.totalCost).toBe(0);
|
||||
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
|
||||
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "usage",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cost: 0.25,
|
||||
totalCost: 0.25,
|
||||
});
|
||||
|
||||
expect(handleEvent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
requestToolApproval,
|
||||
submitAndExitInTerminal,
|
||||
} from "../utils/approval";
|
||||
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { handleEvent, handleTeamEvent } from "../utils/events";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import {
|
||||
c,
|
||||
@@ -183,8 +189,10 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -196,7 +204,9 @@ export async function runAgent(
|
||||
(!event.recoverable || config.verbose) &&
|
||||
event.error.message.trim()
|
||||
) {
|
||||
displayedErrorMessages.add(event.error.message.trim());
|
||||
displayedErrorMessages.add(
|
||||
formatCliErrorMessage(event.error.message).trim(),
|
||||
);
|
||||
}
|
||||
handleEvent(event, config);
|
||||
};
|
||||
@@ -338,8 +348,14 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
@@ -374,7 +390,7 @@ export async function runAgent(
|
||||
}
|
||||
|
||||
if (result.finishReason !== "completed") {
|
||||
const errorText = result.text.trim();
|
||||
const errorText = formatCliErrorMessage(result.text).trim();
|
||||
if (
|
||||
errorText &&
|
||||
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
|
||||
@@ -395,7 +411,7 @@ export async function runAgent(
|
||||
);
|
||||
process.exitCode = 0;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const message = formatCliErrorMessage(err);
|
||||
logCliError(config.logger, "CLI task run failed", { error: err });
|
||||
writeErr(message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: false, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "high" } },
|
||||
),
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning with the selected effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: "low" },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "low" });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("preserves existing reasoning when thinking is unset", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: undefined, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "medium" } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
loadIndividualSubscriptionPlans,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
@@ -24,6 +25,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
@@ -52,6 +58,23 @@ import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { getUIEventEmitter } from "./session-events";
|
||||
|
||||
type ModelChangeReasoningConfig = {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
): ProviderSettings["reasoning"] {
|
||||
if (config.thinking === false) return { enabled: false };
|
||||
if (config.reasoningEffort) {
|
||||
return { enabled: true, effort: config.reasoningEffort };
|
||||
}
|
||||
if (config.thinking === true) return { enabled: true };
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -121,6 +144,7 @@ export async function runInteractive(
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy,
|
||||
tuiToolApprover,
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
@@ -152,6 +176,7 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -160,11 +185,12 @@ export async function runInteractive(
|
||||
resumeSessionId,
|
||||
chatCommandState,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy,
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", event);
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -402,6 +428,12 @@ export async function runInteractive(
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}),
|
||||
loadIndividualSubscriptionPlans: async () =>
|
||||
await loadIndividualSubscriptionPlans({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
clineProviderSettings: options?.clineProviderSettings,
|
||||
}),
|
||||
switchClineAccount: async (organizationId) =>
|
||||
await switchClineAccount({
|
||||
config,
|
||||
@@ -430,6 +462,7 @@ export async function runInteractive(
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
let zeroTurnCost = false;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -476,6 +509,8 @@ export async function runInteractive(
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -517,8 +552,9 @@ export async function runInteractive(
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
@@ -533,7 +569,10 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
@@ -557,6 +596,7 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
@@ -621,12 +661,11 @@ export async function runInteractive(
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
reasoning: config.reasoningEffort
|
||||
? { enabled: true, effort: config.reasoningEffort }
|
||||
: { enabled: false },
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyInteractiveAutoApproveOverride,
|
||||
cloneToolPolicies,
|
||||
resolveInteractiveAutoApprovePolicy,
|
||||
} from "./tool-policies";
|
||||
|
||||
describe("tool policy helpers", () => {
|
||||
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the baseline policies when toggled back on", () => {
|
||||
it("forces all baseline policies to auto-approve when toggled back on", () => {
|
||||
const baseline = {
|
||||
"*": { autoApprove: true },
|
||||
"*": { autoApprove: false },
|
||||
run_commands: { autoApprove: true, enabled: true },
|
||||
editor: { autoApprove: false, enabled: true },
|
||||
};
|
||||
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(target).toEqual(baseline);
|
||||
expect(target).toEqual({
|
||||
"*": { autoApprove: true },
|
||||
run_commands: { autoApprove: true, enabled: true },
|
||||
editor: { autoApprove: true, enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves live per-tool policies from the interactive auto-approve state", () => {
|
||||
const baseline = {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { enabled: true },
|
||||
editor: { autoApprove: false, enabled: true },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "editor",
|
||||
baselinePolicies: baseline,
|
||||
enabled: true,
|
||||
}),
|
||||
).toEqual({ autoApprove: true, enabled: true });
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "run_commands",
|
||||
baselinePolicies: baseline,
|
||||
enabled: false,
|
||||
}),
|
||||
).toEqual({ autoApprove: false });
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "read_files",
|
||||
baselinePolicies: baseline,
|
||||
enabled: false,
|
||||
}),
|
||||
).toEqual({ autoApprove: true, enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInteractiveAutoApprovePolicy(input: {
|
||||
toolName: string;
|
||||
baselinePolicies: Record<string, ToolPolicy>;
|
||||
enabled: boolean;
|
||||
}): ToolPolicy {
|
||||
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
|
||||
const baselinePolicy = {
|
||||
...(input.baselinePolicies["*"] ?? {}),
|
||||
...toolPolicy,
|
||||
};
|
||||
return {
|
||||
...baselinePolicy,
|
||||
autoApprove: input.enabled
|
||||
? true
|
||||
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
|
||||
? (toolPolicy.autoApprove ?? true)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyInteractiveAutoApproveOverride(input: {
|
||||
targetPolicies: Record<string, ToolPolicy>;
|
||||
baselinePolicies: Record<string, ToolPolicy>;
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const nextPolicies: Record<string, ToolPolicy> = input.enabled
|
||||
? cloneToolPolicies(input.baselinePolicies)
|
||||
? Object.fromEntries(
|
||||
Object.entries(input.baselinePolicies).map(([name, policy]) => [
|
||||
name,
|
||||
{
|
||||
...policy,
|
||||
autoApprove: true,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: Object.fromEntries(
|
||||
Object.entries(input.baselinePolicies).map(([name, policy]) => [
|
||||
name,
|
||||
{
|
||||
...policy,
|
||||
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
|
||||
? (policy.autoApprove ?? true)
|
||||
: false,
|
||||
autoApprove: resolveInteractiveAutoApprovePolicy({
|
||||
toolName: name,
|
||||
baselinePolicies: input.baselinePolicies,
|
||||
enabled: false,
|
||||
}).autoApprove,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
|
||||
}
|
||||
|
||||
const globalPolicy = clonePolicy(nextPolicies["*"]);
|
||||
globalPolicy.autoApprove = input.enabled
|
||||
? (input.baselinePolicies["*"]?.autoApprove ?? true)
|
||||
: false;
|
||||
globalPolicy.autoApprove = input.enabled;
|
||||
nextPolicies["*"] = globalPolicy;
|
||||
|
||||
for (const key of Object.keys(input.targetPolicies)) {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { CLINE_BIN } from "./helpers/constants.js";
|
||||
import { clineEnv } from "./helpers/env.js";
|
||||
import { expectVisible } from "./helpers/terminal.js";
|
||||
|
||||
const HELP_TERMINAL = { columns: 120, rows: 50 };
|
||||
// Wide enough that long option descriptions (e.g. --thinking) render on a
|
||||
// single line. At narrower widths commander wraps them, splitting phrases
|
||||
// like "omitted leaves provider default" across lines so the contiguous
|
||||
// getByText assertions below fail.
|
||||
const HELP_TERMINAL = { columns: 200, rows: 50 };
|
||||
|
||||
// ===========================================================================
|
||||
// Root-level flag descriptions
|
||||
@@ -23,10 +27,11 @@ test.describe("root flag descriptions", () => {
|
||||
"verbose output",
|
||||
"Working directory",
|
||||
"Configuration directory",
|
||||
"Set reasoning effort level",
|
||||
"Set reasoning effort:",
|
||||
"Bare --thinking uses medium",
|
||||
"omitted leaves provider default",
|
||||
"consecutive mistakes",
|
||||
"Output messages as JSON",
|
||||
"ACP",
|
||||
"Check for updates and install if available",
|
||||
"Run the kanban app",
|
||||
]);
|
||||
|
||||
@@ -12,6 +12,7 @@ const coreMocks = vi.hoisted(() => {
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
@@ -39,6 +40,11 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
fetchAvailableSubscriptionPlans(input?: {
|
||||
type?: "individual" | "teams";
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -100,6 +106,7 @@ describe("createClineAccountService", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -196,6 +203,7 @@ describe("loadClineAccountSnapshot", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -249,3 +257,48 @@ describe("loadClineAccountSnapshot", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIndividualSubscriptionPlans", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads individual subscription plans through the authorized account service", async () => {
|
||||
const plans = [
|
||||
{
|
||||
id: "plan-1",
|
||||
interval: "Monthly",
|
||||
features: { included: ["Major open-weights models"] },
|
||||
},
|
||||
];
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
|
||||
|
||||
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
|
||||
const result = await loadIndividualSubscriptionPlans({
|
||||
config: makeConfig(),
|
||||
});
|
||||
|
||||
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
|
||||
type: "individual",
|
||||
});
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
@@ -203,6 +204,18 @@ export async function switchClineAccount(input: {
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlans(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { ClineSubscriptionPlan } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -29,6 +37,12 @@ import {
|
||||
} from "../utils/tool-parsing";
|
||||
import { ToolOutput } from "./tool-output";
|
||||
|
||||
function getIndividualPlanFeatures(plans: ClineSubscriptionPlan[]): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function trimLeading(text: string): string {
|
||||
return text.replace(/^\n+/, "");
|
||||
}
|
||||
@@ -260,7 +274,14 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue.";
|
||||
const OUT_OF_CREDITS_MESSAGE =
|
||||
"You have run out of Cline credits. Add credits in the dashboard to continue.";
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -275,7 +296,11 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
content={
|
||||
isClinePassEnabled
|
||||
? CLINE_PASS_ENABLED_OUT_OF_CREDITS_MESSAGE
|
||||
: OUT_OF_CREDITS_MESSAGE
|
||||
}
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
@@ -290,9 +315,108 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.loadIndividualSubscriptionPlans) {
|
||||
return;
|
||||
}
|
||||
let isMounted = true;
|
||||
void props
|
||||
.loadIndividualSubscriptionPlans()
|
||||
.then((plans) => {
|
||||
if (isMounted) {
|
||||
setPlanFeatures(getIndividualPlanFeatures(plans));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the subscription error view usable if plan metadata is unavailable.
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [props.loadIndividualSubscriptionPlans]);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
|
||||
/>
|
||||
{planFeatures.length > 0 && (
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={props.defaultFg}>ClinePass includes:</text>
|
||||
{planFeatures.map((feature) => (
|
||||
<box key={feature} flexDirection="row">
|
||||
<text fg="green" content="✓ " />
|
||||
<text fg={props.defaultFg} selectable>
|
||||
{feature}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={getClineOrgIndividualInferenceSubscriptionMessage()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
@@ -388,6 +512,23 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return (
|
||||
<ClinePassSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -21,6 +21,7 @@ export interface TranscriptScrollHandle {
|
||||
interface ChatMessageListProps {
|
||||
entries: ChatEntry[];
|
||||
isStreaming?: boolean;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
uiMode?: AgentMode;
|
||||
}
|
||||
|
||||
@@ -100,6 +101,9 @@ export const ChatMessageList = forwardRef<
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={accent}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -248,18 +248,44 @@ export function ProviderPickerContent(
|
||||
);
|
||||
}
|
||||
|
||||
export type ExistingProviderAction = "use_existing" | "reconfigure";
|
||||
export type ExistingProviderAction =
|
||||
| "use_existing"
|
||||
| "reconfigure"
|
||||
| "open_subscription";
|
||||
|
||||
export interface ExistingProviderOption {
|
||||
value: ExistingProviderAction;
|
||||
label: string;
|
||||
onSelect?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
return new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function UseExistingOrReconfigureContent(
|
||||
props: ChoiceContext<ExistingProviderAction> & {
|
||||
props: ChoiceContext<ExistingProviderOption> & {
|
||||
providerName: string;
|
||||
extraOptions?: ExistingProviderOption[];
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const options: { value: ExistingProviderAction; label: string }[] = [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
{ value: "reconfigure", label: "Configure again" },
|
||||
];
|
||||
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
|
||||
const options: ExistingProviderOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
{ value: "reconfigure", label: "Configure again" },
|
||||
...(extraOptions ?? []),
|
||||
],
|
||||
[extraOptions],
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -269,7 +295,7 @@ export function UseExistingOrReconfigureContent(
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const opt = options[selected];
|
||||
if (opt) resolve(opt.value);
|
||||
if (opt) resolve(opt);
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
@@ -314,6 +340,59 @@ export function UseExistingOrReconfigureContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function ClinePassSubscriptionContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const subscriptionUrl = useMemo(
|
||||
() =>
|
||||
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
const [status, setStatus] = useState("Opening browser...");
|
||||
|
||||
useEffect(() => {
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened subscription page in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
resolve(true);
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="cyan">
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
<text>{status}</text>
|
||||
|
||||
<text fg="gray">Subscription page:</text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
|
||||
<text fg="gray">
|
||||
<em>Enter or Esc to go back</em>
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelsEntry = {
|
||||
kind: "model";
|
||||
tier: ClineModelTier;
|
||||
model: ClineRecommendedModel;
|
||||
};
|
||||
export type ClineModelPickerEntry = ClineModelsEntry | ClineModelPickerBrowse;
|
||||
export type ClineModelTier = "recommended" | "free" | "clinePass";
|
||||
export type ClineModelPickerExpandedTiers = Record<ClineModelTier, boolean>;
|
||||
export type ClineModelProviderId = "cline" | "cline-pass";
|
||||
|
||||
export interface BuildClineModelEntriesOptions {
|
||||
includeClinePass?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPANDED_TIERS: ClineModelPickerExpandedTiers = {
|
||||
clinePass: false,
|
||||
recommended: false,
|
||||
free: false,
|
||||
};
|
||||
|
||||
export function resolveClineModelDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
options: BuildClineModelEntriesOptions = {},
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [
|
||||
...(options.includeClinePass
|
||||
? data.clinePass.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "clinePass" as const,
|
||||
model,
|
||||
}))
|
||||
: []),
|
||||
...data.recommended.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "recommended" as const,
|
||||
model,
|
||||
})),
|
||||
...data.free.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "free" as const,
|
||||
model,
|
||||
})),
|
||||
{
|
||||
kind: "browse",
|
||||
},
|
||||
];
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function resolveClineModelProviderId(
|
||||
tier: ClineModelTier,
|
||||
): ClineModelProviderId {
|
||||
return tier === "clinePass" ? "cline-pass" : "cline";
|
||||
}
|
||||
|
||||
export function resolveClineModelEntryProviderId(
|
||||
entry: ClineModelPickerEntry,
|
||||
): ClineModelProviderId | undefined {
|
||||
return entry.kind === "model"
|
||||
? resolveClineModelProviderId(entry.tier)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export type ClineModelPickerDisplayRow =
|
||||
| {
|
||||
kind: "header";
|
||||
key: string;
|
||||
label: string;
|
||||
tier: ClineModelTier;
|
||||
isExpanded: boolean;
|
||||
count: number;
|
||||
focusIndex: number;
|
||||
}
|
||||
| {
|
||||
kind: "model";
|
||||
key: string;
|
||||
label: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
selectableIndex: number;
|
||||
focusIndex: number;
|
||||
}
|
||||
| {
|
||||
kind: "browse";
|
||||
key: string;
|
||||
label: string;
|
||||
entryIndex: number;
|
||||
selectableIndex: number;
|
||||
focusIndex: number;
|
||||
};
|
||||
|
||||
function normalizeExpandedTiers(
|
||||
expandedTiers?: Partial<ClineModelPickerExpandedTiers>,
|
||||
): ClineModelPickerExpandedTiers {
|
||||
return {
|
||||
recommended:
|
||||
expandedTiers?.recommended ?? DEFAULT_EXPANDED_TIERS.recommended,
|
||||
free: expandedTiers?.free ?? DEFAULT_EXPANDED_TIERS.free,
|
||||
clinePass: expandedTiers?.clinePass ?? DEFAULT_EXPANDED_TIERS.clinePass,
|
||||
};
|
||||
}
|
||||
|
||||
function countModelsInTier(
|
||||
entries: ClineModelPickerEntry[],
|
||||
tier: ClineModelTier,
|
||||
): number {
|
||||
return entries.reduce(
|
||||
(count, entry) =>
|
||||
count + (entry.kind === "model" && entry.tier === tier ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getVisibleClineModelPickerEntries(
|
||||
entries: ClineModelPickerEntry[],
|
||||
expandedTiers?: Partial<ClineModelPickerExpandedTiers>,
|
||||
): ClineModelPickerEntry[] {
|
||||
const expanded = normalizeExpandedTiers(expandedTiers);
|
||||
return entries.filter((entry) => {
|
||||
if (entry.kind === "browse") return true;
|
||||
return expanded[entry.tier];
|
||||
});
|
||||
}
|
||||
|
||||
const TierTitle: Record<ClineModelTier, string> = {
|
||||
recommended: "Recommended",
|
||||
clinePass: "Cline Pass",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
export function buildClineModelPickerDisplayRows(
|
||||
entries: ClineModelPickerEntry[],
|
||||
knownModels?: Record<string, unknown>,
|
||||
currentModelId?: string,
|
||||
expandedTiers?: Partial<ClineModelPickerExpandedTiers>,
|
||||
): ClineModelPickerDisplayRow[] {
|
||||
const rows: ClineModelPickerDisplayRow[] = [];
|
||||
const expanded = normalizeExpandedTiers(expandedTiers);
|
||||
let lastTier: ClineModelTier | null = null;
|
||||
let selectableIndex = 0;
|
||||
let focusIndex = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
rows.push({
|
||||
kind: "header",
|
||||
key: `tier-${entry.tier}`,
|
||||
label: TierTitle[entry.tier],
|
||||
tier: entry.tier,
|
||||
isExpanded: expanded[entry.tier],
|
||||
count: countModelsInTier(entries, entry.tier),
|
||||
focusIndex,
|
||||
});
|
||||
focusIndex++;
|
||||
}
|
||||
if (!expanded[entry.tier]) continue;
|
||||
|
||||
rows.push({
|
||||
kind: "model",
|
||||
key: `${entry.tier}-${entry.model.id}-${i}`,
|
||||
label: resolveClineModelDisplayName(entry.model.id, knownModels),
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModelId === entry.model.id,
|
||||
entryIndex: i,
|
||||
selectableIndex,
|
||||
focusIndex,
|
||||
});
|
||||
selectableIndex++;
|
||||
focusIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "browse",
|
||||
key: "browse-all",
|
||||
label: "Browse all models...",
|
||||
entryIndex: i,
|
||||
selectableIndex,
|
||||
focusIndex,
|
||||
});
|
||||
selectableIndex++;
|
||||
focusIndex++;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function countFocusableRows(rows: ClineModelPickerDisplayRow[]): number {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export function getClineModelPickerDisplayRowsWindow(
|
||||
rows: ClineModelPickerDisplayRow[],
|
||||
selected: number,
|
||||
maxVisibleRows: number,
|
||||
) {
|
||||
if (rows.length <= maxVisibleRows) {
|
||||
return {
|
||||
visibleRows: rows,
|
||||
aboveCount: 0,
|
||||
belowCount: 0,
|
||||
showAbove: false,
|
||||
showBelow: false,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedRowIndex = Math.max(
|
||||
0,
|
||||
rows.findIndex((row) => row.focusIndex === selected),
|
||||
);
|
||||
let visibleLimit = maxVisibleRows;
|
||||
let start = 0;
|
||||
let end = rows.length;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const showAbove = start > 0;
|
||||
const showBelow = end < rows.length;
|
||||
visibleLimit = Math.max(
|
||||
1,
|
||||
maxVisibleRows - (showAbove ? 1 : 0) - (showBelow ? 1 : 0),
|
||||
);
|
||||
start = Math.max(0, selectedRowIndex - Math.floor(visibleLimit / 2));
|
||||
if (start + visibleLimit > rows.length) {
|
||||
start = Math.max(0, rows.length - visibleLimit);
|
||||
}
|
||||
end = Math.min(rows.length, start + visibleLimit);
|
||||
}
|
||||
|
||||
if (start > 0 && countFocusableRows(rows.slice(0, start)) === 0) {
|
||||
start = 0;
|
||||
const showBelow = rows.length > maxVisibleRows;
|
||||
visibleLimit = Math.max(1, maxVisibleRows - (showBelow ? 1 : 0));
|
||||
end = Math.min(rows.length, visibleLimit);
|
||||
}
|
||||
|
||||
const aboveCount = countFocusableRows(rows.slice(0, start));
|
||||
const belowCount = countFocusableRows(rows.slice(end));
|
||||
|
||||
return {
|
||||
visibleRows: rows.slice(start, end),
|
||||
aboveCount,
|
||||
belowCount,
|
||||
showAbove: aboveCount > 0,
|
||||
showBelow: belowCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getClineModelPickerRowByFocusIndex(
|
||||
rows: ClineModelPickerDisplayRow[],
|
||||
focusIndex: number,
|
||||
): ClineModelPickerDisplayRow | undefined {
|
||||
return rows.find((row) => row.focusIndex === focusIndex);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { ClineRecommendedModelsData } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildClineModelPickerDisplayRows,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
getVisibleClineModelPickerEntries,
|
||||
resolveClineModelProviderId,
|
||||
} from "./cline-model-picker-utils";
|
||||
|
||||
const data: ClineRecommendedModelsData = {
|
||||
clinePass: [
|
||||
{
|
||||
id: "cline-pass/glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
description: "Cline Pass model",
|
||||
tags: ["PASS"],
|
||||
},
|
||||
],
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Recommended Claude model",
|
||||
tags: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
description: "Recommended Codex model",
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
free: [
|
||||
{
|
||||
id: "deepseek/deepseek-chat",
|
||||
name: "DeepSeek Chat",
|
||||
description: "Free DeepSeek model",
|
||||
tags: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "qwen/qwen3-coder",
|
||||
name: "Qwen3 Coder",
|
||||
description: "Free Qwen model",
|
||||
tags: ["FREE"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("cline model picker helpers", () => {
|
||||
it("builds tiered entries with browse action at the end while hiding Cline Pass by default", () => {
|
||||
expect(buildClineModelEntries(data)).toMatchObject([
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "anthropic/claude-sonnet-4.6" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "openai/gpt-5.3-codex" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "free",
|
||||
model: { id: "deepseek/deepseek-chat" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "free",
|
||||
model: { id: "qwen/qwen3-coder" },
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes Cline Pass models when enabled", () => {
|
||||
expect(
|
||||
buildClineModelEntries(data, { includeClinePass: true }).slice(0, 2),
|
||||
).toMatchObject([
|
||||
{
|
||||
kind: "model",
|
||||
tier: "clinePass",
|
||||
model: { id: "cline-pass/glm-5.1" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "anthropic/claude-sonnet-4.6" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps Cline Pass models to the cline-pass provider", () => {
|
||||
expect(resolveClineModelProviderId("clinePass")).toBe("cline-pass");
|
||||
expect(resolveClineModelProviderId("recommended")).toBe("cline");
|
||||
expect(resolveClineModelProviderId("free")).toBe("cline");
|
||||
});
|
||||
|
||||
it("keeps section header focus indexes stable when sections expand or collapse", () => {
|
||||
const entries = buildClineModelEntries(data, { includeClinePass: true });
|
||||
const collapsedRows = buildClineModelPickerDisplayRows(
|
||||
entries,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
clinePass: false,
|
||||
recommended: true,
|
||||
free: true,
|
||||
},
|
||||
);
|
||||
const expandedRows = buildClineModelPickerDisplayRows(
|
||||
entries,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
clinePass: true,
|
||||
recommended: true,
|
||||
free: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
collapsedRows.find(
|
||||
(row) => row.kind === "header" && row.tier === "clinePass",
|
||||
)?.focusIndex,
|
||||
).toBe(0);
|
||||
expect(
|
||||
expandedRows.find(
|
||||
(row) => row.kind === "header" && row.tier === "clinePass",
|
||||
)?.focusIndex,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("filters collapsed tiers while keeping browse selectable", () => {
|
||||
const visible = getVisibleClineModelPickerEntries(
|
||||
buildClineModelEntries(data),
|
||||
{
|
||||
recommended: true,
|
||||
free: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
visible.map((entry) =>
|
||||
entry.kind === "model" ? entry.model.id : "browse",
|
||||
),
|
||||
).toEqual([
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"openai/gpt-5.3-codex",
|
||||
"browse",
|
||||
]);
|
||||
});
|
||||
|
||||
it("windows display rows around focus indexes, including expandable headers", () => {
|
||||
const entries = buildClineModelEntries(data);
|
||||
const rows = buildClineModelPickerDisplayRows(
|
||||
entries,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
recommended: false,
|
||||
free: true,
|
||||
},
|
||||
);
|
||||
|
||||
const headers = rows.filter((row) => row.kind === "header");
|
||||
expect(headers).toMatchObject([
|
||||
{ tier: "recommended", focusIndex: 0, isExpanded: false },
|
||||
{ tier: "free", focusIndex: 1, isExpanded: true },
|
||||
]);
|
||||
|
||||
const freeRows = rows.filter((row) => row.kind === "model");
|
||||
expect(freeRows).toMatchObject([
|
||||
{ entryIndex: 2, selectableIndex: 0, focusIndex: 2 },
|
||||
{ entryIndex: 3, selectableIndex: 1, focusIndex: 3 },
|
||||
]);
|
||||
|
||||
const window = getClineModelPickerDisplayRowsWindow(rows, 3, 3);
|
||||
expect(
|
||||
window.visibleRows.some(
|
||||
(row) => row.kind === "model" && row.entryIndex === 3,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,28 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerDisplayRow,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerExpandedTiers,
|
||||
type ClineModelTier,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
} from "./cline-model-picker-utils";
|
||||
|
||||
export {
|
||||
buildClineModelEntries,
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerDisplayRow,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerExpandedTiers,
|
||||
type ClineModelProviderId,
|
||||
type ClineModelTier,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
getClineModelPickerRowByFocusIndex,
|
||||
getVisibleClineModelPickerEntries,
|
||||
resolveClineModelEntryProviderId,
|
||||
resolveClineModelProviderId,
|
||||
} from "./cline-model-picker-utils";
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_ROWS = 10;
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
@@ -40,6 +30,23 @@ function tagColor(tag: string): string {
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -61,56 +68,18 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
function ClineModelRow(props: {
|
||||
row: Extract<ClineModelPickerDisplayRow, { kind: "model" | "browse" }>;
|
||||
isSelected: boolean;
|
||||
onSelect?: (selectableIndex: number, entry: ClineModelPickerEntry) => void;
|
||||
entry: ClineModelPickerEntry;
|
||||
}) {
|
||||
const { row, isSelected, onSelect, entry } = props;
|
||||
const isBrowse = row.kind === "browse";
|
||||
return (
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
marginTop={isBrowse ? 1 : 0}
|
||||
onMouseDown={() => onSelect?.(row.selectableIndex, entry)}
|
||||
overflow="hidden"
|
||||
height={1}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSelected ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isSelected ? palette.textOnSelection : isBrowse ? "gray" : undefined
|
||||
}
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
{row.kind === "model" &&
|
||||
row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSelected ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.kind === "model" && row.isCurrent && (
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : palette.success}
|
||||
flexShrink={0}
|
||||
>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
@@ -119,35 +88,8 @@ export function ClineModelPicker(props: {
|
||||
loading?: boolean;
|
||||
knownModels?: Record<string, unknown>;
|
||||
currentModelId?: string;
|
||||
expandedTiers?: Partial<ClineModelPickerExpandedTiers>;
|
||||
maxVisibleRows?: number;
|
||||
onEntrySelect?: (
|
||||
selectableIndex: number,
|
||||
entry: ClineModelPickerEntry,
|
||||
) => void;
|
||||
onToggleTier?: (tier: ClineModelTier) => void;
|
||||
}) {
|
||||
const {
|
||||
entries,
|
||||
selected,
|
||||
loading,
|
||||
knownModels,
|
||||
currentModelId,
|
||||
expandedTiers,
|
||||
maxVisibleRows = MAX_VISIBLE_ROWS,
|
||||
onEntrySelect,
|
||||
onToggleTier,
|
||||
} = props;
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
buildClineModelPickerDisplayRows(
|
||||
entries,
|
||||
knownModels,
|
||||
currentModelId,
|
||||
expandedTiers,
|
||||
),
|
||||
[entries, knownModels, currentModelId, expandedTiers],
|
||||
);
|
||||
const { entries, selected, loading, knownModels, currentModelId } = props;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -158,81 +100,82 @@ export function ClineModelPicker(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
|
||||
getClineModelPickerDisplayRowsWindow(displayRows, selected, maxVisibleRows);
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
|
||||
if (showAbove) {
|
||||
rows.push(
|
||||
<box key="more-above" paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25b2"} {aboveCount} more
|
||||
</text>
|
||||
</box>,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
const isSel = i === selected;
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.kind === "header") {
|
||||
const isSelected = row.focusIndex === selected;
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
}
|
||||
|
||||
const tags = entry.model.tags;
|
||||
const name = resolveDisplayName(entry.model.id, knownModels);
|
||||
const isCurrent = currentModelId === entry.model.id;
|
||||
rows.push(
|
||||
<box
|
||||
key={row.key}
|
||||
key={entry.model.id}
|
||||
paddingX={1}
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => onToggleTier?.(row.tier)}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSelected ? "\u276f" : " "}
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.isExpanded ? "▾" : "▸"}
|
||||
<text fg={isSel ? palette.textOnSelection : undefined}>{name}</text>
|
||||
{tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{isCurrent && (
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
<box
|
||||
key="browse-all"
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={1}
|
||||
>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text fg={isSelected ? palette.textOnSelection : "gray"}>
|
||||
{row.label}
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"}>
|
||||
Browse all models...
|
||||
</text>
|
||||
</box>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = entries[row.entryIndex];
|
||||
if (!entry) continue;
|
||||
rows.push(
|
||||
<box marginLeft={4}>
|
||||
<ClineModelRow
|
||||
key={row.key}
|
||||
row={row}
|
||||
entry={entry}
|
||||
isSelected={row.focusIndex === selected}
|
||||
onSelect={onEntrySelect}
|
||||
/>
|
||||
</box>,
|
||||
);
|
||||
}
|
||||
|
||||
if (showBelow) {
|
||||
rows.push(
|
||||
<box key="more-below" paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25bc"} {belowCount} more
|
||||
</text>
|
||||
</box>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingTop={1}>
|
||||
{rows}
|
||||
</box>
|
||||
);
|
||||
return <box flexDirection="column">{rows}</box>;
|
||||
}
|
||||
|
||||
@@ -3,32 +3,11 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerExpandedTiers,
|
||||
type ClineModelProviderId,
|
||||
type ClineModelTier,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
getClineModelPickerRowByFocusIndex,
|
||||
getVisibleClineModelPickerEntries,
|
||||
resolveClineModelEntryProviderId,
|
||||
} from "./cline-model-picker";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
export const BROWSE_ALL_ACTION = "__browse_all__";
|
||||
const MAX_VISIBLE_ROWS = 10;
|
||||
|
||||
export interface ClineModelSelection {
|
||||
modelId: string;
|
||||
providerId: ClineModelProviderId;
|
||||
}
|
||||
|
||||
export type ClineModelSelectorResult =
|
||||
| ClineModelSelection
|
||||
| typeof BROWSE_ALL_ACTION
|
||||
| typeof CHANGE_PROVIDER_ACTION;
|
||||
|
||||
type ClineModelEntriesState =
|
||||
| { status: "loading"; message: string }
|
||||
@@ -41,8 +20,25 @@ function tagColor(tag: string): string {
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
props: ChoiceContext<ClineModelSelectorResult> & {
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
@@ -60,55 +56,53 @@ export function ClineModelSelectorContent(
|
||||
} = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
const [expandedTiers, setExpandedTiers] =
|
||||
useState<ClineModelPickerExpandedTiers>({
|
||||
clinePass: false,
|
||||
recommended: true,
|
||||
free: true,
|
||||
});
|
||||
|
||||
const visibleEntries = useMemo(
|
||||
() => getVisibleClineModelPickerEntries(entries, expandedTiers),
|
||||
[entries, expandedTiers],
|
||||
);
|
||||
const displayRows = useMemo(() => {
|
||||
return buildClineModelPickerDisplayRows(
|
||||
entries,
|
||||
knownModels,
|
||||
currentModel,
|
||||
expandedTiers,
|
||||
);
|
||||
}, [entries, knownModels, currentModel, expandedTiers]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected((value) =>
|
||||
Math.min(value, Math.max(0, displayRows.length - 1)),
|
||||
);
|
||||
}, [displayRows.length]);
|
||||
|
||||
const toggleTier = (tier: ClineModelTier) => {
|
||||
setExpandedTiers((prev) => ({ ...prev, [tier]: !prev[tier] }));
|
||||
};
|
||||
|
||||
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
|
||||
getClineModelPickerDisplayRowsWindow(
|
||||
displayRows,
|
||||
selected,
|
||||
MAX_VISIBLE_ROWS,
|
||||
);
|
||||
|
||||
const resolveEntry = (selectableIndex: number) => {
|
||||
const entry = visibleEntries[selectableIndex];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
resolve({
|
||||
modelId: entry.model.id,
|
||||
providerId: resolveClineModelEntryProviderId(entry) ?? "cline",
|
||||
});
|
||||
} else {
|
||||
resolve(BROWSE_ALL_ACTION);
|
||||
const rows: {
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
key: entry.model.id,
|
||||
kind: "model",
|
||||
label: resolveDisplayName(entry.model.id, knownModels),
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModel === entry.model.id,
|
||||
entryIndex: i,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
key: "browse-all",
|
||||
kind: "browse",
|
||||
label: "Browse all models...",
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
return rows;
|
||||
}, [entries, knownModels, currentModel]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -124,20 +118,20 @@ export function ClineModelSelectorContent(
|
||||
resolve(CHANGE_PROVIDER_ACTION);
|
||||
return;
|
||||
}
|
||||
const row = getClineModelPickerRowByFocusIndex(displayRows, selected);
|
||||
if (!row) return;
|
||||
if (row.kind === "header") {
|
||||
toggleTier(row.tier);
|
||||
return;
|
||||
const entry = entries[selected];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
resolve(entry.model.id);
|
||||
} else {
|
||||
resolve(BROWSE_ALL_ACTION);
|
||||
}
|
||||
resolveEntry(row.selectableIndex);
|
||||
return;
|
||||
}
|
||||
const total = displayRows.length;
|
||||
const total = entries.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
if (!onProvider) {
|
||||
setSelected((s) => (s <= 0 ? total - 1 : Math.min(s - 1, total - 1)));
|
||||
setSelected((s) => (s <= 0 ? total - 1 : s - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -158,45 +152,16 @@ export function ClineModelSelectorContent(
|
||||
<ProviderRow providerName={currentProviderName} focused={onProvider} />
|
||||
|
||||
<box flexDirection="column">
|
||||
{showAbove && (
|
||||
<box paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25b2"} {aboveCount} more
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
{visibleRows.map((row) => {
|
||||
{displayRows.map((row, idx) => {
|
||||
if (row.kind === "header") {
|
||||
const isSel = row.focusIndex === selected && !onProvider;
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
onMouseDown={() => toggleTier(row.tier)}
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{row.isExpanded ? "▾" : "▸"}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"}>
|
||||
{row.label}
|
||||
</text>
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<text fg="gray">{row.label}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
const isSel = row.focusIndex === selected && !onProvider;
|
||||
const isSel = row.entryIndex === selected && !onProvider;
|
||||
const isGray = row.kind === "browse";
|
||||
return (
|
||||
<box
|
||||
@@ -206,9 +171,6 @@ export function ClineModelSelectorContent(
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={row.kind === "browse" ? 1 : 0}
|
||||
onMouseDown={() => resolveEntry(row.selectableIndex)}
|
||||
overflow="hidden"
|
||||
height={1}
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
@@ -223,17 +185,16 @@ export function ClineModelSelectorContent(
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
{row.kind === "model" &&
|
||||
row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.kind === "model" && row.isCurrent && (
|
||||
{row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.isCurrent && (
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
@@ -244,13 +205,6 @@ export function ClineModelSelectorContent(
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{showBelow && (
|
||||
<box paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25bc"} {belowCount} more
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
@@ -261,7 +215,7 @@ export function ClineModelSelectorContent(
|
||||
}
|
||||
|
||||
export function ClineModelSelectorDialogContent(
|
||||
props: ChoiceContext<ClineModelSelectorResult> & {
|
||||
props: ChoiceContext<string> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ModelSelectorContent(
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
models: ModelOption[];
|
||||
allowCustomModel?: boolean;
|
||||
showCustomModelId?: boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
@@ -144,7 +144,7 @@ export function ModelSelectorContent(
|
||||
currentModel,
|
||||
currentProviderName,
|
||||
models,
|
||||
allowCustomModel = true,
|
||||
showCustomModelId = true,
|
||||
} = props;
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState(() => {
|
||||
@@ -166,7 +166,7 @@ export function ModelSelectorContent(
|
||||
return scored.map((r) => r.model);
|
||||
}, [models, search]);
|
||||
|
||||
const optionCount = filtered.length + (allowCustomModel ? 1 : 0);
|
||||
const optionCount = filtered.length + (showCustomModelId ? 1 : 0);
|
||||
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -190,7 +190,7 @@ export function ModelSelectorContent(
|
||||
resolve(model.key);
|
||||
return;
|
||||
}
|
||||
if (allowCustomModel && safeSelected === filtered.length) {
|
||||
if (showCustomModelId && safeSelected === filtered.length) {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
setCustomModelError("");
|
||||
@@ -292,7 +292,7 @@ export function ModelSelectorContent(
|
||||
dimmed={onProvider}
|
||||
currentModel={currentModel}
|
||||
onSelect={resolve}
|
||||
allowCustomModel={allowCustomModel}
|
||||
showCustomModelId={showCustomModelId}
|
||||
onCreateCustomModel={() => {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
@@ -411,7 +411,7 @@ function ModelList(props: {
|
||||
dimmed?: boolean;
|
||||
currentModel: string;
|
||||
onSelect: (key: string) => void;
|
||||
allowCustomModel: boolean;
|
||||
showCustomModelId: boolean;
|
||||
onCreateCustomModel: () => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -420,12 +420,12 @@ function ModelList(props: {
|
||||
dimmed,
|
||||
currentModel,
|
||||
onSelect,
|
||||
allowCustomModel,
|
||||
showCustomModelId,
|
||||
onCreateCustomModel,
|
||||
} = props;
|
||||
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
|
||||
...items.map((model) => ({ type: "model" as const, model })),
|
||||
...(allowCustomModel ? [{ type: "custom" as const }] : []),
|
||||
...(showCustomModelId ? ([{ type: "custom" as const }] as const) : []),
|
||||
];
|
||||
|
||||
if (rows.length <= MAX_VISIBLE) {
|
||||
|
||||
@@ -55,18 +55,18 @@ describe("formatStatusBarUsageText", () => {
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
showCost: true,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
it("displays subscription message when the provider is a subscription provider", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
).toBe("(12,345 tokens) $0.00 (included with your subscription)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import {
|
||||
shouldShowCliUsageCost,
|
||||
shouldShowCliUsageCoveredBySubscription,
|
||||
} from "../../utils/usage-cost-display";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
@@ -46,14 +49,31 @@ function formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatCostText(providerId: string, totalCost: number): string {
|
||||
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
|
||||
return "$0.00 (included with your subscription)";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return formatCost(totalCost);
|
||||
}
|
||||
|
||||
export function formatStatusBarUsageText(input: {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
providerId: string;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
const costText = formatCostText(input.providerId, input.totalCost);
|
||||
|
||||
if (!costText) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
return `${tokens} ${costText}`;
|
||||
}
|
||||
|
||||
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
|
||||
@@ -152,7 +172,6 @@ export function StatusBar(props: StatusBarProps) {
|
||||
const bar = hasMaxInputTokens
|
||||
? createContextBar(totalTokens, maxInputTokens)
|
||||
: undefined;
|
||||
const showUsageCost = shouldShowCliUsageCost(props.providerId);
|
||||
|
||||
// Available content width after accounting for padding.
|
||||
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
|
||||
@@ -169,7 +188,7 @@ export function StatusBar(props: StatusBarProps) {
|
||||
const usageText = formatStatusBarUsageText({
|
||||
totalTokens,
|
||||
totalCost,
|
||||
showCost: showUsageCost,
|
||||
providerId: props.providerId,
|
||||
});
|
||||
const contextText = bar
|
||||
? ` ${bar.filled}${bar.empty} ${usageText}`
|
||||
|
||||
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
|
||||
const [uiMode, setUiMode] = useState<AgentMode>(
|
||||
config.mode === "plan" ? "plan" : "act",
|
||||
);
|
||||
const [autoApproveAll, _setAutoApproveAll] = useState(
|
||||
config.toolPolicies["*"]?.autoApprove !== false,
|
||||
);
|
||||
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
|
||||
const autoApproveAllRef = useRef(initialAutoApproveAll);
|
||||
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
|
||||
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
|
||||
getCliCompactionMode(config),
|
||||
);
|
||||
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
|
||||
}, []);
|
||||
|
||||
const toggleAutoApprove = useCallback(() => {
|
||||
_setAutoApproveAll((prev) => {
|
||||
const next = !prev;
|
||||
onAutoApproveChange(next);
|
||||
return next;
|
||||
});
|
||||
const next = !autoApproveAllRef.current;
|
||||
autoApproveAllRef.current = next;
|
||||
onAutoApproveChange(next);
|
||||
_setAutoApproveAll(next);
|
||||
}, [onAutoApproveChange]);
|
||||
|
||||
const setCompactionMode = useCallback(
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
appendEntry({ kind: "error", text: event.error.message });
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
@@ -19,8 +18,9 @@ import {
|
||||
import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderAction,
|
||||
type ExistingProviderOption,
|
||||
OAuthLoginContent,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
@@ -30,7 +30,6 @@ import { buildClineModelEntries } from "../components/model-selector/cline-model
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
type ClineModelSelectorResult,
|
||||
} from "../components/model-selector/cline-model-selector";
|
||||
import {
|
||||
buildModelOptions,
|
||||
@@ -80,8 +79,34 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
function usesClineModelSelector(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
function providerToExistingProviderOptions(input: {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
dialog: DialogActions;
|
||||
termHeight: number;
|
||||
}): ExistingProviderOption[] {
|
||||
if (input.providerId !== "cline-pass") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "open_subscription",
|
||||
label: "Open ClinePass subscription page",
|
||||
onSelect: async () => {
|
||||
await input.dialog.choice<boolean>({
|
||||
style: { maxHeight: input.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<ClinePassSubscriptionContent
|
||||
{...ctx}
|
||||
providerName={input.providerName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function runProviderChange(
|
||||
@@ -108,14 +133,33 @@ async function runProviderChange(
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
const action = await dialog.choice<ExistingProviderAction>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
|
||||
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
|
||||
),
|
||||
let option: ExistingProviderOption | undefined;
|
||||
const extraOptions = providerToExistingProviderOptions({
|
||||
providerId: newProviderId,
|
||||
providerName: displayName,
|
||||
dialog,
|
||||
termHeight,
|
||||
});
|
||||
if (!action) return false;
|
||||
needsAuth = action === "reconfigure";
|
||||
while (true) {
|
||||
option = await dialog.choice<ExistingProviderOption>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
|
||||
<UseExistingOrReconfigureContent
|
||||
{...ctx}
|
||||
providerName={displayName}
|
||||
extraOptions={extraOptions}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (!option) return false;
|
||||
if (option.onSelect) {
|
||||
await option.onSelect();
|
||||
option = undefined;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
needsAuth = option.value === "reconfigure";
|
||||
}
|
||||
|
||||
if (needsAuth) {
|
||||
@@ -270,8 +314,6 @@ export function useModelSelector(opts: {
|
||||
}
|
||||
|
||||
let pickingModel = true;
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
@@ -299,19 +341,17 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (usesClineModelSelector(config.providerId)) {
|
||||
const clineResult = await dialog.choice<ClineModelSelectorResult>({
|
||||
if (config.providerId === "cline") {
|
||||
const clineResult = await dialog.choice<string>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ClineModelSelectorResult>) => (
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
<ClineModelSelectorDialogContent
|
||||
{...ctx}
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels(), {
|
||||
includeClinePass: isClinePassEnabled,
|
||||
})
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -333,7 +373,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
allowCustomModel={false}
|
||||
showCustomModelId={config.providerId !== "cline-pass"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -343,7 +383,6 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
config.modelId = browseResult;
|
||||
config.providerId = "cline";
|
||||
const browseModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === browseResult,
|
||||
);
|
||||
@@ -380,28 +419,9 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId !== clineResult.providerId) {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const resolved = await resolveProviderConfig(
|
||||
clineResult.providerId,
|
||||
{
|
||||
loadLatestOnInit: true,
|
||||
loadPrivateOnAuth: true,
|
||||
failOnError: false,
|
||||
},
|
||||
manager.getProviderConfig(clineResult.providerId, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
modelOptions = buildModelOptions(
|
||||
resolved?.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
config.knownModels = resolved?.knownModels;
|
||||
}
|
||||
config.modelId = clineResult.modelId;
|
||||
config.providerId = clineResult.providerId;
|
||||
config.modelId = clineResult;
|
||||
const selectedModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === clineResult.modelId,
|
||||
(m: ModelOption) => m.key === clineResult,
|
||||
);
|
||||
if (selectedModel?.supportsReasoning) {
|
||||
const currentLevel: ThinkingLevel = config.reasoningEffort
|
||||
@@ -444,7 +464,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
allowCustomModel={config.providerId !== "cline-pass"}
|
||||
showCustomModelId={config.providerId !== "cline-pass"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
|
||||
import {
|
||||
@@ -376,7 +377,7 @@ export function usePromptInputController(input: {
|
||||
if (!turnErrorReportedRef.current) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
text: formatCliErrorMessage(error),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -879,6 +879,7 @@ function App(props: TuiProps) {
|
||||
repoStatus,
|
||||
textareaRef: promptInput.textareaRef,
|
||||
transcriptScrollRef,
|
||||
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
|
||||
queuedPrompts,
|
||||
selectedQueuedPromptId,
|
||||
editingQueuedPrompt,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentEvent,
|
||||
AgentMode,
|
||||
CheckpointEntry,
|
||||
ClineSubscriptionPlan,
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -129,6 +130,7 @@ export interface TuiProps {
|
||||
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
|
||||
loadWelcomeLine?: () => Promise<string | undefined>;
|
||||
loadClineAccount: () => Promise<ClineAccountSnapshot>;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
switchClineAccount: (organizationId?: string | null) => Promise<void>;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
|
||||
@@ -50,6 +50,7 @@ export function ChatView(props: {
|
||||
};
|
||||
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
|
||||
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
|
||||
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
|
||||
autocomplete?: AutocompleteDropdownProps;
|
||||
queuedPrompts?: QueuedPromptItem[];
|
||||
selectedQueuedPromptId?: string | null;
|
||||
@@ -89,6 +90,7 @@ export function ChatView(props: {
|
||||
ref={props.transcriptScrollRef}
|
||||
entries={session.entries}
|
||||
isStreaming={session.isStreaming}
|
||||
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
|
||||
uiMode={session.uiMode}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
startClineDeviceAuth: vi.fn(),
|
||||
completeClineDeviceAuth: vi.fn(),
|
||||
saveLocalProviderOAuthCredentials: vi.fn(),
|
||||
identifyFeatureFlagsAccount: vi.fn(async () => {}),
|
||||
openMock: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
|
||||
|
||||
vi.mock("open", () => ({ default: hoisted.openMock }));
|
||||
|
||||
vi.mock("../../../utils/feature-flags", () => ({
|
||||
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
|
||||
}));
|
||||
|
||||
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
|
||||
|
||||
// Minimal stand-in for a telemetry service. The auth helpers must forward this
|
||||
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
hoisted.startClineDeviceAuth.mockReset();
|
||||
hoisted.completeClineDeviceAuth.mockReset();
|
||||
hoisted.saveLocalProviderOAuthCredentials.mockReset();
|
||||
hoisted.identifyFeatureFlagsAccount.mockReset();
|
||||
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
|
||||
hoisted.openMock.mockReset();
|
||||
hoisted.openMock.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
// Identity, not deep-equal — we are validating the exact reference flows
|
||||
// through so opt-out / common metadata stays consistent.
|
||||
expect(telemetryArg).toBe(fakeTelemetry);
|
||||
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pass telemetry when none is provided (back-compat)", () => {
|
||||
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
access: "a",
|
||||
refresh: "r",
|
||||
expires: 0,
|
||||
accountId: "acct-1",
|
||||
email: "user@example.com",
|
||||
});
|
||||
|
||||
runDeviceCodeAuthFlow({
|
||||
@@ -146,6 +156,10 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
// emitted by completeClineDeviceAuth, so passing telemetry to the start
|
||||
// helper would double-emit the event.
|
||||
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
|
||||
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
|
||||
id: "acct-1",
|
||||
email: "user@example.com",
|
||||
});
|
||||
expect(hoisted.openMock).toHaveBeenCalledWith(
|
||||
"https://verify?user_code=uc",
|
||||
{ wait: false },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
|
||||
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
function isClineAccountOAuthProvider(providerId: string): boolean {
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
providerId: OnboardingOAuthProviderId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
@@ -56,6 +61,12 @@ export function runOAuthAuthFlow(input: {
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
if (isClineAccountOAuthProvider(input.providerId)) {
|
||||
void identifyFeatureFlagsAccount({
|
||||
id: credentials.accountId,
|
||||
email: credentials.email,
|
||||
}).catch(() => {});
|
||||
}
|
||||
input.onComplete(input.providerId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -118,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
if (isClineAccountOAuthProvider(input.providerId)) {
|
||||
void identifyFeatureFlagsAccount({
|
||||
id: credentials.accountId,
|
||||
email: credentials.email,
|
||||
}).catch(() => {});
|
||||
}
|
||||
input.onComplete(input.providerId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveProviderConfig,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
@@ -21,13 +22,7 @@ import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerExpandedTiers,
|
||||
type ClineModelProviderId,
|
||||
type ClineModelTier,
|
||||
getVisibleClineModelPickerEntries,
|
||||
resolveClineModelEntryProviderId,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
@@ -53,11 +48,13 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -78,6 +75,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
|
||||
[props.providerSettingsManager],
|
||||
);
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [step, setStep] = useState<OnboardingStep>("menu");
|
||||
const [menuSelected, setMenuSelected] = useState(0);
|
||||
const [oauthProvider, setOauthProvider] = useState("");
|
||||
@@ -160,7 +165,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const createCustomModelItem = useCallback(
|
||||
(_search: string, filteredItems: SearchableItem[]) => {
|
||||
if (activeProviderId === "cline-pass") return undefined;
|
||||
if (activeProviderId === "cline-pass") {
|
||||
return undefined;
|
||||
}
|
||||
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -178,49 +185,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
// Cline featured model picker
|
||||
const recommended = useClineRecommendedModels();
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() =>
|
||||
recommended.data
|
||||
? buildClineModelEntries(recommended.data, {
|
||||
includeClinePass: isClinePassEnabled,
|
||||
})
|
||||
: [],
|
||||
[recommended.data, isClinePassEnabled],
|
||||
);
|
||||
const [clineExpandedTiers, setClineExpandedTiers] =
|
||||
useState<ClineModelPickerExpandedTiers>({
|
||||
clinePass: false,
|
||||
recommended: true,
|
||||
free: true,
|
||||
});
|
||||
const visibleClineEntries = useMemo(
|
||||
() => getVisibleClineModelPickerEntries(clineEntries, clineExpandedTiers),
|
||||
[clineEntries, clineExpandedTiers],
|
||||
);
|
||||
const [clineKnownModels, setClineKnownModels] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
const clineDisplayRows = useMemo(
|
||||
() =>
|
||||
buildClineModelPickerDisplayRows(
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
undefined,
|
||||
clineExpandedTiers,
|
||||
),
|
||||
[clineEntries, clineKnownModels, clineExpandedTiers],
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
useEffect(() => {
|
||||
setClineModelSelected((selected) =>
|
||||
Math.min(selected, Math.max(0, clineDisplayRows.length - 1)),
|
||||
);
|
||||
}, [clineDisplayRows.length]);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [clineKnownModels, setClineKnownModels] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
@@ -296,7 +271,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
setActiveProviderName(provider?.name ?? providerId);
|
||||
setModelsDefaultId(provider?.defaultModelId ?? "");
|
||||
if (providerId === "cline") {
|
||||
if (shouldUseFeaturedClineModelPicker(providerId)) {
|
||||
setClineModelSelected(0);
|
||||
setStep("cline_model");
|
||||
} else if (providerId === "openai-compatible") {
|
||||
@@ -347,7 +322,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
(providerId: OnboardingOAuthProviderId) => {
|
||||
if (providerId === "cline") {
|
||||
if (isClineProvider(providerId)) {
|
||||
startDeviceCodeFlow(providerId);
|
||||
return;
|
||||
}
|
||||
@@ -576,16 +551,16 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}, [customModelId, completeModelSelection]);
|
||||
|
||||
const saveClineModelSelection = useCallback(
|
||||
(modelId: string, modelName: string, providerId: ClineModelProviderId) => {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
(modelId: string, modelName: string) => {
|
||||
const existing =
|
||||
providerSettingsManager.getProviderSettings(activeProviderId);
|
||||
providerSettingsManager.saveProviderSettings(
|
||||
{
|
||||
...(existing ?? { provider: providerId }),
|
||||
...(existing ?? { provider: activeProviderId }),
|
||||
model: modelId,
|
||||
},
|
||||
{ setLastUsed: true },
|
||||
);
|
||||
setActiveProviderId(providerId);
|
||||
setSelectedModelId(modelId);
|
||||
if (clineModelReasoningIds.has(modelId)) {
|
||||
setSelectedModelName(modelName);
|
||||
@@ -595,34 +570,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStep("done");
|
||||
}
|
||||
},
|
||||
[clineModelReasoningIds, providerSettingsManager],
|
||||
[activeProviderId, clineModelReasoningIds, providerSettingsManager],
|
||||
);
|
||||
|
||||
const selectClineModelEntry = useCallback(
|
||||
(entry: ClineModelPickerEntry | undefined) => {
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
const providerId = resolveClineModelEntryProviderId(entry) ?? "cline";
|
||||
if (providerId === "cline-pass") {
|
||||
setActiveProviderId(providerId);
|
||||
setActiveProviderName("Cline Pass");
|
||||
setStep("model_picker");
|
||||
loadModelsForProvider(providerId);
|
||||
return;
|
||||
}
|
||||
saveClineModelSelection(entry.model.id, entry.model.name, providerId);
|
||||
return;
|
||||
}
|
||||
setStep("model_picker");
|
||||
loadModelsForProvider(activeProviderId);
|
||||
},
|
||||
[activeProviderId, loadModelsForProvider, saveClineModelSelection],
|
||||
);
|
||||
|
||||
const toggleClineModelTier = useCallback((tier: ClineModelTier) => {
|
||||
setClineExpandedTiers((prev) => ({ ...prev, [tier]: !prev[tier] }));
|
||||
}, []);
|
||||
|
||||
const saveThinkingLevel = useCallback(
|
||||
(level: ThinkingLevel) => {
|
||||
const existing =
|
||||
@@ -676,11 +626,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
onExit: props.onExit,
|
||||
oauthProvider,
|
||||
activeProviderId,
|
||||
menuOptions,
|
||||
menuSelected,
|
||||
providerList,
|
||||
modelList,
|
||||
clineEntries: visibleClineEntries,
|
||||
clineDisplayRows,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
@@ -711,8 +661,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
selectClineModelEntry,
|
||||
toggleClineModelTier,
|
||||
saveClineModelSelection,
|
||||
saveCodexCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
@@ -731,7 +680,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
clineEntries,
|
||||
clineExpandedTiers,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
deviceError,
|
||||
@@ -751,15 +699,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setCustomModelId(value);
|
||||
setCustomModelError("");
|
||||
},
|
||||
handleClineModelEntrySelect: (
|
||||
_selectableIndex: number,
|
||||
entry: ClineModelPickerEntry,
|
||||
) => {
|
||||
selectClineModelEntry(entry);
|
||||
},
|
||||
handleClineModelTierToggle: toggleClineModelTier,
|
||||
handleModelItemSelect: selectModelItem,
|
||||
menuSelected,
|
||||
menuOptions,
|
||||
modelItems,
|
||||
modelList,
|
||||
modelsLoading,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
import { useKeyboard } from "@opentui/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type {
|
||||
ClineModelPickerDisplayRow,
|
||||
ClineModelPickerEntry,
|
||||
ClineModelTier,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
|
||||
import type { SearchableListState } from "../../components/searchable-list";
|
||||
import type { OnboardingOAuthProviderId } from "./auth";
|
||||
import {
|
||||
isOnboardingOAuthProviderId,
|
||||
type OnboardingOAuthProviderId,
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
MAIN_MENU,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
type ThinkingLevel,
|
||||
@@ -21,11 +20,11 @@ export function useOnboardingKeyboard(input: {
|
||||
onExit: () => void;
|
||||
oauthProvider: string;
|
||||
activeProviderId: string;
|
||||
menuOptions: MenuOption[];
|
||||
menuSelected: number;
|
||||
providerList: SearchableListState;
|
||||
modelList: SearchableListState;
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineDisplayRows: ClineModelPickerDisplayRow[];
|
||||
clineModelSelected: number;
|
||||
thinkingSelected: number;
|
||||
setStep: (step: OnboardingStep) => void;
|
||||
@@ -48,8 +47,7 @@ export function useOnboardingKeyboard(input: {
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
selectClineModelEntry: (entry: ClineModelPickerEntry | undefined) => void;
|
||||
toggleClineModelTier: (tier: ClineModelTier) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
@@ -139,17 +137,21 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "menu") {
|
||||
if (key.name === "up") {
|
||||
input.setMenuSelected((s) => (s <= 0 ? MAIN_MENU.length - 1 : s - 1));
|
||||
input.setMenuSelected((s) =>
|
||||
s <= 0 ? input.menuOptions.length - 1 : s - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
input.setMenuSelected((s) => (s >= MAIN_MENU.length - 1 ? 0 : s + 1));
|
||||
input.setMenuSelected((s) =>
|
||||
s >= input.menuOptions.length - 1 ? 0 : s + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
const option = MAIN_MENU[input.menuSelected];
|
||||
const option = input.menuOptions[input.menuSelected];
|
||||
if (!option) return;
|
||||
if (option.value === "cline" || option.value === "openai-codex") {
|
||||
if (isOnboardingOAuthProviderId(option.value)) {
|
||||
input.startOAuthFlow(option.value);
|
||||
} else {
|
||||
input.setStep("byo_provider");
|
||||
@@ -203,7 +205,7 @@ export function useOnboardingKeyboard(input: {
|
||||
}
|
||||
|
||||
if (input.step === "cline_model") {
|
||||
const total = input.clineDisplayRows.length;
|
||||
const total = input.clineEntries.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
input.setClineModelSelected((s) => (s <= 0 ? total - 1 : s - 1));
|
||||
@@ -214,13 +216,14 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
const row = input.clineDisplayRows[input.clineModelSelected];
|
||||
if (!row) return;
|
||||
if (row.kind === "header") {
|
||||
input.toggleClineModelTier(row.tier);
|
||||
return;
|
||||
const entry = input.clineEntries[input.clineModelSelected];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
input.saveClineModelSelection(entry.model.id, entry.model.name);
|
||||
} else {
|
||||
input.setStep("model_picker");
|
||||
input.loadModelsForProvider(input.activeProviderId);
|
||||
}
|
||||
input.selectClineModelEntry(input.clineEntries[row.selectableIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
toProviderEntry,
|
||||
} from "./model";
|
||||
|
||||
describe("onboarding model helpers", () => {
|
||||
it("hides ClinePass from the main menu unless its feature flag is enabled", () => {
|
||||
expect(
|
||||
getMainMenuOptions().some((option) => option.value === "cline-pass"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getMainMenuOptions({ isClinePassEnabled: false }).some(
|
||||
(option) => option.value === "cline-pass",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getMainMenuOptions({ isClinePassEnabled: true }).some(
|
||||
(option) => option.value === "cline-pass",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps provider catalog entries into onboarding provider entries", () => {
|
||||
expect(
|
||||
toProviderEntry({
|
||||
@@ -112,7 +130,14 @@ describe("onboarding model helpers", () => {
|
||||
|
||||
it("formats OAuth provider labels for onboarding status views", () => {
|
||||
expect(getOAuthProviderLabel("cline")).toBe("Cline");
|
||||
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
|
||||
expect(getOAuthProviderLabel("openai-codex")).toBe("ChatGPT");
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,12 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
detail: "Latest models with regular free promos",
|
||||
icon: "\u263a",
|
||||
},
|
||||
{
|
||||
label: "Sign in with ClinePass",
|
||||
value: "cline-pass",
|
||||
detail: "Low cost subscription for everyone",
|
||||
icon: "\u2726",
|
||||
},
|
||||
{
|
||||
label: "Sign in with ChatGPT",
|
||||
value: "openai-codex",
|
||||
@@ -57,6 +63,14 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getMainMenuOptions(options?: {
|
||||
isClinePassEnabled?: boolean;
|
||||
}): MenuOption[] {
|
||||
return MAIN_MENU.filter(
|
||||
(option) => option.value !== "cline-pass" || options?.isClinePassEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
export interface OnboardingResult {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -139,6 +153,9 @@ export function toModelEntriesFromKnownModels(
|
||||
}
|
||||
|
||||
export function getOAuthProviderLabel(providerId: string): string {
|
||||
if (providerId === "cline-pass") {
|
||||
return "ClinePass";
|
||||
}
|
||||
if (providerId === "cline") {
|
||||
return "Cline";
|
||||
}
|
||||
@@ -147,3 +164,7 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
return providerId;
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
import {
|
||||
ClineModelPicker,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelPickerExpandedTiers,
|
||||
type ClineModelTier,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
type SearchableItem,
|
||||
@@ -22,7 +20,7 @@ import {
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { MAIN_MENU, THINKING_LEVELS } from "./model";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -435,17 +433,11 @@ export function OnboardingProviderPickerScreen(props: {
|
||||
|
||||
export function OnboardingClineModelScreen(props: {
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineExpandedTiers: ClineModelPickerExpandedTiers;
|
||||
clineKnownModels: Record<string, unknown> | undefined;
|
||||
clineModelSelected: number;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
onEntrySelect: (
|
||||
selectableIndex: number,
|
||||
entry: ClineModelPickerEntry,
|
||||
) => void;
|
||||
onToggleTier: (tier: ClineModelTier) => void;
|
||||
recommendedLoading: boolean;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
@@ -467,9 +459,6 @@ export function OnboardingClineModelScreen(props: {
|
||||
selected={props.clineModelSelected}
|
||||
loading={props.recommendedLoading}
|
||||
knownModels={props.clineKnownModels}
|
||||
expandedTiers={props.clineExpandedTiers}
|
||||
onEntrySelect={props.onEntrySelect}
|
||||
onToggleTier={props.onToggleTier}
|
||||
/>
|
||||
|
||||
<text fg="gray" paddingX={1}>
|
||||
@@ -644,6 +633,7 @@ export function OnboardingThinkingLevelScreen(props: {
|
||||
|
||||
export function OnboardingMainMenuScreen(props: {
|
||||
contentWidth: number;
|
||||
menuOptions: MenuOption[];
|
||||
menuSelected: number;
|
||||
mouse: MouseTrackerState;
|
||||
}) {
|
||||
@@ -682,7 +672,7 @@ export function OnboardingMainMenuScreen(props: {
|
||||
marginTop={1}
|
||||
gap={0}
|
||||
>
|
||||
{MAIN_MENU.map((option, i) => {
|
||||
{props.menuOptions.map((option, i) => {
|
||||
const isSel = i === props.menuSelected;
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -111,13 +111,10 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
return (
|
||||
<OnboardingClineModelScreen
|
||||
clineEntries={state.clineEntries}
|
||||
clineExpandedTiers={state.clineExpandedTiers}
|
||||
clineKnownModels={state.clineKnownModels}
|
||||
clineModelSelected={state.clineModelSelected}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
onEntrySelect={state.handleClineModelEntrySelect}
|
||||
onToggleTier={state.handleClineModelTierToggle}
|
||||
mouse={mouse}
|
||||
recommendedLoading={state.recommendedLoading}
|
||||
/>
|
||||
@@ -169,6 +166,7 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
return (
|
||||
<OnboardingMainMenuScreen
|
||||
contentWidth={contentWidth}
|
||||
menuOptions={state.menuOptions}
|
||||
menuSelected={state.menuSelected}
|
||||
mouse={mouse}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
describe("cline-pass-errors", () => {
|
||||
it("recognizes both raw and formatted ClinePass subscription messages", () => {
|
||||
expect(
|
||||
isClinePassSubscriptionError(
|
||||
"the user is not subscribed to required model plan",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const sdkFormatted =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const formatted = getCliNotSubscribedMessage();
|
||||
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("no access to clinepass subscription models yet") &&
|
||||
normalized.includes("subscribe to clinepass")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
if (isClineNotSubscribedError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineNotSubscribedError" ||
|
||||
isClineNotSubscribedMessage(error.message) ||
|
||||
isFormattedClinePassSubscriptionMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineNotSubscribedMessage(error) ||
|
||||
isFormattedClinePassSubscriptionMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -87,22 +87,29 @@ export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
export function setCliFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
setCliFeatureFlagsAccountContext(account);
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliReasoning } from "./reasoning";
|
||||
|
||||
describe("resolveCliReasoning", () => {
|
||||
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit --thinking none as disabled reasoning", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
thinkingExplicitlySet: true,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning settings", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: true,
|
||||
thinkingExplicitlySet: true,
|
||||
reasoningEffort: "low",
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { effort: "none" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted active effort when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true, effort: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import type { CliReasoningEffort } from "./types";
|
||||
|
||||
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
|
||||
|
||||
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
|
||||
export interface ResolveCliReasoningInput {
|
||||
thinking: boolean;
|
||||
thinkingExplicitlySet?: boolean;
|
||||
reasoningEffort?: CliReasoningEffort;
|
||||
persistedReasoning?: ProviderSettings["reasoning"];
|
||||
}
|
||||
|
||||
export interface ResolvedCliReasoning {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: ActiveCliReasoningEffort;
|
||||
}
|
||||
|
||||
function isActiveReasoningEffort(
|
||||
effort: unknown,
|
||||
): effort is ActiveCliReasoningEffort {
|
||||
return (
|
||||
typeof effort === "string" &&
|
||||
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliReasoning({
|
||||
thinking,
|
||||
thinkingExplicitlySet,
|
||||
reasoningEffort,
|
||||
persistedReasoning,
|
||||
}: ResolveCliReasoningInput): ResolvedCliReasoning {
|
||||
if (thinkingExplicitlySet) {
|
||||
return {
|
||||
thinking,
|
||||
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
|
||||
? reasoningEffort
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
persistedReasoning?.enabled === false ||
|
||||
persistedReasoning?.effort === "none"
|
||||
) {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
|
||||
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
|
||||
return { thinking: true, reasoningEffort: persistedReasoning.effort };
|
||||
}
|
||||
|
||||
if (persistedReasoning?.enabled === true) {
|
||||
return { thinking: true, reasoningEffort: "medium" };
|
||||
}
|
||||
|
||||
return { thinking: undefined, reasoningEffort: undefined };
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
|
||||
timeoutSeconds?: number;
|
||||
sandbox: boolean;
|
||||
sandboxDataDir?: string;
|
||||
thinking: boolean;
|
||||
thinking?: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
defaultToolAutoApprove: boolean;
|
||||
|
||||
@@ -3,3 +3,9 @@ import { Llms } from "@cline/core";
|
||||
export function shouldShowCliUsageCost(providerId: string): boolean {
|
||||
return Llms.shouldShowProviderUsageCost(providerId);
|
||||
}
|
||||
|
||||
export function shouldShowCliUsageCoveredBySubscription(
|
||||
providerId: string,
|
||||
): boolean {
|
||||
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
|
||||
}
|
||||
|
||||
@@ -1,325 +1,16 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
}
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export interface FieldDef {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
return /^\d+$/.test(value)
|
||||
? undefined
|
||||
: "Telegram user ID must contain digits only";
|
||||
}
|
||||
|
||||
function validateSlackTeamId(value: string): string | undefined {
|
||||
return /^T[A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
function validateSlackUserId(value: string): string | undefined {
|
||||
return /^[UW][A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
export const PLATFORMS: PlatformDef[] = [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
type: "polling",
|
||||
hint: "Easiest to set up. No public URL needed.",
|
||||
fields: [
|
||||
{
|
||||
flag: "-k",
|
||||
label: "Bot token",
|
||||
placeholder: "7123456789:AAH...",
|
||||
required: true,
|
||||
help: [
|
||||
"Open Telegram and start a chat with @BotFather",
|
||||
"Send /newbot and follow the prompts",
|
||||
"BotFather gives you this after creating the bot",
|
||||
"It looks like 7123456789:AAHxxx...",
|
||||
],
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt:
|
||||
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
|
||||
fields: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "Your Telegram user ID",
|
||||
placeholder: "123456789",
|
||||
help: [
|
||||
"Message @userinfobot on Telegram",
|
||||
"It will reply with your numeric user ID",
|
||||
],
|
||||
requiredMessage: "User ID is required to restrict access",
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
placeholder: "xoxb-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to api.slack.com/apps and create a new app",
|
||||
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt: "Restrict which Slack users can interact with the bot?",
|
||||
fields: [
|
||||
{
|
||||
key: "teamId",
|
||||
label: "Allowed Slack workspace ID",
|
||||
placeholder: "T01ABC123",
|
||||
help: [
|
||||
"Open your Slack workspace URL in a browser",
|
||||
"The workspace ID is the segment after /client/, for example T01ABC123",
|
||||
],
|
||||
requiredMessage: "Workspace ID is required to restrict access",
|
||||
validate: validateSlackTeamId,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "Allowed Slack member ID",
|
||||
placeholder: "U01ABC123",
|
||||
help: [
|
||||
"Click a user's name in Slack, then View full profile",
|
||||
"Click ... and Copy member ID",
|
||||
],
|
||||
requiredMessage: "Member ID is required to restrict access",
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
type: "webhook",
|
||||
hint: "Requires a Discord app and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--application-id",
|
||||
label: "Application ID",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to discord.com/developers/applications",
|
||||
"Create a new app, copy the Application ID",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
help: ["Go to Bot section, create a bot, copy the token"],
|
||||
},
|
||||
{
|
||||
flag: "--public-key",
|
||||
label: "Public key",
|
||||
required: true,
|
||||
help: ["Found in General Information of your app"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
help: [
|
||||
"Base URL for the connector",
|
||||
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
type: "webhook",
|
||||
hint: "Requires Meta developer account and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--phone-number-id",
|
||||
label: "Phone number ID",
|
||||
required: true,
|
||||
help: ["From your WhatsApp Business account in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--access-token",
|
||||
label: "Access token",
|
||||
required: true,
|
||||
help: ["Generate a permanent token in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--app-secret",
|
||||
label: "App secret",
|
||||
required: true,
|
||||
help: ["Found in App Settings > Basic"],
|
||||
},
|
||||
{
|
||||
flag: "--verify-token",
|
||||
label: "Webhook verify token",
|
||||
placeholder: "my-verify-token",
|
||||
required: true,
|
||||
help: ["Any string you choose, used to verify webhook setup"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gchat",
|
||||
name: "Google Chat",
|
||||
type: "webhook",
|
||||
hint: "Requires Google Cloud project and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--credentials-json",
|
||||
label: "Service account credentials JSON",
|
||||
required: true,
|
||||
help: [
|
||||
"Create a service account in Google Cloud Console",
|
||||
"Download the credentials JSON file",
|
||||
"Paste the JSON content here",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
type: "webhook",
|
||||
hint: "React to Linear issues and comments.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--api-key",
|
||||
label: "API key",
|
||||
required: true,
|
||||
help: ["Go to Linear Settings > API > Personal API keys"],
|
||||
},
|
||||
{
|
||||
flag: "--webhook-secret",
|
||||
label: "Webhook signing secret",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to Settings > API > Webhooks, create one",
|
||||
"Copy the signing secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
|
||||
@@ -51,6 +51,19 @@ interface UrlServerConfig {
|
||||
authMode: RemoteAuthMode;
|
||||
}
|
||||
|
||||
export interface McpAddDefaults {
|
||||
name?: string;
|
||||
type?: McpTransport["type"];
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RunMcpWizardOptions {
|
||||
initialAction?: "add";
|
||||
addDefaults?: McpAddDefaults;
|
||||
exitAfterInitialAction?: boolean;
|
||||
}
|
||||
|
||||
export function parseStdioCommand(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
@@ -96,12 +109,15 @@ export function parseStdioCommand(input: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async function collectStdioTransport(): Promise<McpTransport | null> {
|
||||
async function collectStdioTransport(
|
||||
defaultCommand?: string,
|
||||
): Promise<McpTransport | null> {
|
||||
p.log.info("Quoted arguments and escaped spaces are supported");
|
||||
|
||||
const command = await p.text({
|
||||
message: "Command to run",
|
||||
placeholder: "npx -y @modelcontextprotocol/server-filesystem",
|
||||
initialValue: defaultCommand,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "Command is required";
|
||||
return undefined;
|
||||
@@ -141,10 +157,12 @@ async function collectStdioTransport(): Promise<McpTransport | null> {
|
||||
|
||||
async function collectUrlTransport(
|
||||
type: "sse" | "streamableHttp",
|
||||
defaultUrl?: string,
|
||||
): Promise<UrlServerConfig | null> {
|
||||
const url = await p.text({
|
||||
message: "Server URL",
|
||||
placeholder: "https://example.com/mcp",
|
||||
initialValue: defaultUrl,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "URL is required";
|
||||
try {
|
||||
@@ -211,10 +229,11 @@ async function collectUrlTransport(
|
||||
};
|
||||
}
|
||||
|
||||
async function actionAdd(): Promise<void> {
|
||||
async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
const name = await p.text({
|
||||
message: "Server name",
|
||||
placeholder: "my-mcp-server",
|
||||
initialValue: defaults?.name,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "Name is required";
|
||||
const existing = loadServers();
|
||||
@@ -228,6 +247,7 @@ async function actionAdd(): Promise<void> {
|
||||
|
||||
const type = await p.select({
|
||||
message: "Server type",
|
||||
initialValue: defaults?.type,
|
||||
options: [
|
||||
{
|
||||
value: "stdio",
|
||||
@@ -251,9 +271,12 @@ async function actionAdd(): Promise<void> {
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport();
|
||||
transport = await collectStdioTransport(defaults?.command);
|
||||
} else {
|
||||
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
|
||||
const config = await collectUrlTransport(
|
||||
type as "sse" | "streamableHttp",
|
||||
defaults?.url,
|
||||
);
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
}
|
||||
@@ -407,9 +430,25 @@ async function actionAuthorizeOAuth(): Promise<void> {
|
||||
await authorizeOAuth(name);
|
||||
}
|
||||
|
||||
export async function runMcpWizard(): Promise<number> {
|
||||
export async function runMcpWizard(
|
||||
options: RunMcpWizardOptions = {},
|
||||
): Promise<number> {
|
||||
p.intro("MCP Servers");
|
||||
|
||||
if (options.initialAction === "add") {
|
||||
let initialActionExitCode = 0;
|
||||
try {
|
||||
await actionAdd(options.addDefaults);
|
||||
} catch (err) {
|
||||
initialActionExitCode = 1;
|
||||
p.log.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (options.exitAfterInitialAction === true) {
|
||||
p.outro("Done");
|
||||
return initialActionExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
let keepGoing = true;
|
||||
while (keepGoing) {
|
||||
const action = await p.select({
|
||||
|
||||
@@ -17,6 +17,7 @@ function toErrorMessage(error: unknown): string {
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
@@ -33,6 +34,9 @@ export async function authorizeMcpServerOAuthWithBrowser(
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
if (options.throwOnError === true) {
|
||||
throw error instanceof Error ? error : new Error(toErrorMessage(error));
|
||||
}
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
|
||||
@@ -57,6 +57,17 @@ describe("MCP wizard settings", () => {
|
||||
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
|
||||
});
|
||||
|
||||
it("creates the settings file when adding a server to a missing path", async () => {
|
||||
const settingsPath = await useTempSettingsPath();
|
||||
|
||||
addServer("added", { type: "stdio", command: "npx", args: ["server"] });
|
||||
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(parsed.mcpServers ?? {})).toEqual(["added"]);
|
||||
});
|
||||
|
||||
it("parses quoted stdio command arguments", () => {
|
||||
expect(
|
||||
parseStdioCommand('npx -y "@scope/server name" --root "my dir"'),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface McpServerEntry {
|
||||
@@ -56,28 +57,6 @@ export function loadServers(): McpServerEntry[] {
|
||||
}
|
||||
}
|
||||
|
||||
function readRawSettings(): Record<string, unknown> {
|
||||
const path = getSettingsPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readRawServers(): Record<string, unknown> {
|
||||
const settings = readRawSettings();
|
||||
const servers = settings.mcpServers;
|
||||
return servers && typeof servers === "object" && !Array.isArray(servers)
|
||||
? { ...(servers as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
|
||||
function getOwnServerRecord(
|
||||
servers: Record<string, unknown>,
|
||||
name: string,
|
||||
@@ -92,62 +71,85 @@ function getOwnServerRecord(
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function writeServers(servers: Record<string, unknown>): void {
|
||||
const path = getSettingsPath();
|
||||
const settings = readRawSettings();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ ...settings, mcpServers: servers }, null, 2)}\n`,
|
||||
);
|
||||
/**
|
||||
* Mutate the MCP settings file through @cline/core's locked read-update-write
|
||||
* helper. The mutator must be synchronous and pure; the helper may call it more
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function addServer(name: string, transport: McpTransport): void {
|
||||
const servers = readRawServers();
|
||||
servers[name] = { transport };
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
servers[name] = { transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeServer(name: string): boolean {
|
||||
const servers = readRawServers();
|
||||
if (!(name in servers)) return false;
|
||||
delete servers[name];
|
||||
writeServers(servers);
|
||||
return true;
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateServer(name: string, transport: McpTransport): void {
|
||||
const servers = readRawServers();
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function clearServerOAuth(name: string): void {
|
||||
const servers = readRawServers();
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
return;
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
writeServers(servers);
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
const servers = readRawServers();
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
writeServers(servers);
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev": "bun run src/dev.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"smoke:options": "bun run src/validate-options.ts",
|
||||
"test": "bunx vitest run --config vitest.config.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -47,6 +49,9 @@ function normalizePublicUrl(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -85,12 +90,26 @@ export function resolveClineHubServerOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
if (!roomSecret) return publicUrl;
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
@@ -22,6 +27,7 @@ import {
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
import { fetchMarketplaceCatalog } from "./server/marketplace";
|
||||
import {
|
||||
loadModels,
|
||||
runProviderOAuthLogin,
|
||||
@@ -52,17 +58,33 @@ export interface ClineHubDashboardServer {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
function isAuthorizedBrowserRequest(url: URL): boolean {
|
||||
if (!roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === roomSecret;
|
||||
}
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
@@ -76,6 +98,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
@@ -84,9 +121,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
if (!isAuthorizedBrowserRequest(url)) {
|
||||
return createJsonResponse({ error: "invalid_room_secret" }, 401);
|
||||
}
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
@@ -99,6 +133,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
if (url.pathname === "/config.json") {
|
||||
return createJsonResponse(browserConfig);
|
||||
}
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets.serve(url.pathname);
|
||||
},
|
||||
websocket: {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Users/test/.bun/bin/bun",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Users/test/.bun/bin/bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compiled CLI subcommands without Bun flags", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Applications/Cline/bin/cline",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Applications/Cline/bin/cline",
|
||||
childArgs: ["connect", "telegram", "--bot-token", "token"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Node", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Windows Node when launching the source CLI", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "node.exe",
|
||||
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips terminal color codes from connector command failures", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe("unknown option '--conditions=development'");
|
||||
});
|
||||
|
||||
it("turns Telegram unauthorized responses into a token validation message", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe(
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
@@ -13,6 +16,68 @@ import type {
|
||||
import { cliIndexPath, workspaceRoot } from "./deps";
|
||||
import { asRecord, asString } from "./utils";
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath = options.cliPath ?? cliIndexPath;
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const launcher = (process.versions as Record<string, string | undefined>).bun
|
||||
? process.execPath
|
||||
: "bun";
|
||||
const child = spawn(
|
||||
launcher,
|
||||
["--conditions=development", cliIndexPath, "connect", ...args],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
const { launcher, childArgs } = buildCliConnectCommand(args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
normalizeConnectorError,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
|
||||
@@ -4,15 +4,20 @@ import {
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
formatProviderOAuthApiKey,
|
||||
getLocalProviderModels,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -27,6 +32,12 @@ import {
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
deleteMcpServer,
|
||||
ensureMcpSettingsFile,
|
||||
@@ -52,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
|
||||
"delete_routine_schedule",
|
||||
]);
|
||||
|
||||
async function resolveHubClineAccountAuthToken(input: {
|
||||
settings?: ProviderSettings;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const credentials = input.settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
|
||||
: null;
|
||||
if (!credentials || !input.settings) {
|
||||
return getPersistedProviderApiKey("cline", input.settings);
|
||||
}
|
||||
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
"cline",
|
||||
input.settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
|
||||
return formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
}
|
||||
|
||||
export async function handleDesktopCommand(
|
||||
ctx: HubContext,
|
||||
command: string,
|
||||
@@ -128,10 +172,18 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
const apiBaseUrl =
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const authToken = await resolveHubClineAccountAuthToken({
|
||||
settings,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -213,6 +265,27 @@ export async function handleDesktopCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
|
||||
|
||||
describe("isWebviewRoute", () => {
|
||||
it.each([
|
||||
"/",
|
||||
"/chat",
|
||||
"/sessions",
|
||||
"/models",
|
||||
"/customizations",
|
||||
"/rules",
|
||||
"/hooks",
|
||||
"/mcp",
|
||||
"/plugins",
|
||||
"/skills",
|
||||
"/agents",
|
||||
"/tools",
|
||||
"/marketplace",
|
||||
"/marketplace/mcp",
|
||||
"/marketplace/skills",
|
||||
"/marketplace/plugins",
|
||||
"/channels",
|
||||
"/schedules",
|
||||
"/settings",
|
||||
"/settings/providers",
|
||||
])("matches dashboard SPA route %s", (pathname) => {
|
||||
expect(isWebviewRoute(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat nested marketplace asset requests as SPA routes", () => {
|
||||
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebviewIndexHtml", () => {
|
||||
it("rewrites relative built asset URLs so deep links can refresh", () => {
|
||||
expect(
|
||||
normalizeWebviewIndexHtml(
|
||||
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
|
||||
),
|
||||
).toBe(
|
||||
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the persisted theme bootstrap once", () => {
|
||||
const normalized = normalizeWebviewIndexHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
);
|
||||
|
||||
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
|
||||
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,25 @@ export function createTextResponse(text: string, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
const NO_STORE_HEADERS = {
|
||||
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
};
|
||||
|
||||
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
|
||||
(() => {
|
||||
try {
|
||||
const theme = window.localStorage.getItem("cline-hub-theme");
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case ".html":
|
||||
@@ -36,20 +55,47 @@ function contentTypeFor(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isWebviewRoute(pathname: string): boolean {
|
||||
export function isWebviewRoute(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname === "/index.html" ||
|
||||
pathname === "/chat" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/models" ||
|
||||
pathname === "/customizations" ||
|
||||
pathname === "/rules" ||
|
||||
pathname === "/hooks" ||
|
||||
pathname === "/mcp" ||
|
||||
pathname === "/plugins" ||
|
||||
pathname === "/skills" ||
|
||||
pathname === "/agents" ||
|
||||
pathname === "/tools" ||
|
||||
pathname === "/marketplace" ||
|
||||
pathname === "/marketplace/mcp" ||
|
||||
pathname === "/marketplace/skills" ||
|
||||
pathname === "/marketplace/plugins" ||
|
||||
pathname === "/channels" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/settings" ||
|
||||
pathname.startsWith("/settings/")
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWebviewIndexHtml(html: string): string {
|
||||
const normalized = html
|
||||
.replaceAll('src="./', 'src="/')
|
||||
.replaceAll('href="./', 'href="/');
|
||||
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
|
||||
}
|
||||
|
||||
function renderDevIndexHtml(devServerUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${THEME_BOOTSTRAP_SCRIPT}
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script type="module">
|
||||
@@ -60,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<script type="module" src="${devServerUrl}/@vite/client"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -74,6 +120,14 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
export class WebviewAssets {
|
||||
constructor(private readonly webviewDistDir: string) {}
|
||||
|
||||
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (!(await indexFile.exists())) return undefined;
|
||||
const html = await indexFile.text();
|
||||
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
|
||||
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
|
||||
}
|
||||
|
||||
private resolveStaticPath(pathname: string): string | undefined {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const requested = decoded === "/" ? "/index.html" : decoded;
|
||||
@@ -89,8 +143,11 @@ export class WebviewAssets {
|
||||
private async serveIndex(): Promise<Response> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
return createTextResponse(
|
||||
@@ -103,7 +160,10 @@ export class WebviewAssets {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
|
||||
if (devServerUrl && isWebviewRoute(pathname)) {
|
||||
return new Response(renderDevIndexHtml(devServerUrl), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isWebviewRoute(pathname)) {
|
||||
@@ -112,12 +172,37 @@ export class WebviewAssets {
|
||||
|
||||
const filePath = this.resolveStaticPath(pathname);
|
||||
if (!filePath) return createTextResponse("not found", 404);
|
||||
const file = Bun.file(filePath);
|
||||
let responsePath = filePath;
|
||||
let file = Bun.file(responsePath);
|
||||
if (
|
||||
!(await file.exists()) &&
|
||||
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
|
||||
) {
|
||||
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
|
||||
if (currentMainAssetPath) {
|
||||
responsePath = currentMainAssetPath;
|
||||
file = Bun.file(responsePath);
|
||||
}
|
||||
}
|
||||
if (!(await file.exists())) {
|
||||
return createTextResponse("not found", 404);
|
||||
}
|
||||
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
|
||||
pathname,
|
||||
);
|
||||
return new Response(file, {
|
||||
headers: { "content-type": contentTypeFor(filePath) },
|
||||
headers: {
|
||||
"content-type": contentTypeFor(responsePath),
|
||||
"cache-control": isHashedAsset
|
||||
? IMMUTABLE_ASSET_CACHE
|
||||
: NO_STORE_HEADERS["cache-control"],
|
||||
...(isHashedAsset
|
||||
? {}
|
||||
: {
|
||||
pragma: NO_STORE_HEADERS.pragma,
|
||||
expires: NO_STORE_HEADERS.expires,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMarketplaceMcpInput,
|
||||
fetchMarketplaceCatalog,
|
||||
installMarketplaceEntry,
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntry,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
|
||||
describe("marketplace installer", () => {
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalClineDir = process.env.CLINE_DIR;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = originalClineDir;
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createInstalledOfficialPlugin(
|
||||
clineDir: string,
|
||||
slug: string,
|
||||
): string {
|
||||
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
const installPath = join(
|
||||
clineDir,
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${slug}-${hash}`,
|
||||
);
|
||||
mkdirSync(join(installPath, "package"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(installPath, "package.json"),
|
||||
JSON.stringify({ name: slug }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(installPath, "package", "index.ts"),
|
||||
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
|
||||
"utf8",
|
||||
);
|
||||
return installPath;
|
||||
}
|
||||
|
||||
it("maps remote MCP catalog args to MCP settings shape", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer <token>",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "context7",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
},
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps stdio MCP catalog args to command and args", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
|
||||
).toEqual({
|
||||
name: "filesystem",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "/tmp"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves server flags after stdio MCP command args begin", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"search",
|
||||
"npx",
|
||||
"-y",
|
||||
"server",
|
||||
"--transport",
|
||||
"stdio",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "search",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "--transport", "stdio"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs skills globally for Cline without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
|
||||
"---\nname: web-design-guidelines\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "web-design-guidelines",
|
||||
type: "skill",
|
||||
name: "Web Design Guidelines",
|
||||
install: {
|
||||
args: [
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips skill install commands when the global skill already exists", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Cline SDK is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports Cline global skills as marketplace-installed", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["skill:cline-sdk"] });
|
||||
});
|
||||
|
||||
it("accepts skill installs that create Cline global skills", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
const clineDir = join(homeDir, ".cline");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Cline SDK globally for Cline.",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes Cline global marketplace skills without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "removed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Cline SDK.",
|
||||
});
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report project-local skills as marketplace-installed globals", () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
name: "cline-sdk",
|
||||
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("rejects skill installs that exit zero but report failure", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Failed to install 1",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("Skill install failed");
|
||||
});
|
||||
|
||||
it("redacts common secret formats from failed install output", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
"Authorization: Bearer stdout-token\nAuthorization: Basic basic-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
stderr:
|
||||
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
|
||||
}));
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain("Authorization: Bearer [redacted]");
|
||||
expect(message).toContain("Authorization: [redacted]");
|
||||
expect(message).not.toContain("Authorization: Bearer [redacted]]");
|
||||
expect(message).toContain("api key [redacted]");
|
||||
expect(message).toContain("OPENAI_API_KEY=[redacted]");
|
||||
expect(message).toContain("TOKEN=[redacted]");
|
||||
expect(message).toContain("password is [redacted]");
|
||||
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
|
||||
expect(message).not.toContain("stdout-token");
|
||||
expect(message).not.toContain("basic-token");
|
||||
expect(message).not.toContain("stdout-key");
|
||||
expect(message).not.toContain("compound-key");
|
||||
expect(message).not.toContain("stderr-token");
|
||||
expect(message).not.toContain("stderr-password");
|
||||
expect(message).not.toContain("anthropic-secret");
|
||||
});
|
||||
|
||||
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".agents", "skills"), "");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Cannot install skill globally because ~/.agents/skills is not writable",
|
||||
);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects skill installs that do not create a global skill", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Installation complete",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("was not found in Cline's global skills directories");
|
||||
});
|
||||
|
||||
it("runs official plugin installs through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs MCP installs through the current Cline CLI without prompts", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Context7.",
|
||||
details: {
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uninstalls official marketplace plugins through the shared core service", async () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Goal.",
|
||||
});
|
||||
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await uninstallMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry({
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Context7.",
|
||||
});
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("uninstalls local MCP servers by name", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive({
|
||||
type: "mcp",
|
||||
id: "context7",
|
||||
name: "context7",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled context7.",
|
||||
});
|
||||
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
|
||||
});
|
||||
|
||||
it("uninstalls local skills by removing their configured skill directory", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive(
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
path: skillPath,
|
||||
},
|
||||
{ workspaceRoot },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Review.",
|
||||
});
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports official plugin marketplace entries installed from Cline home", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("does not report plugin inventory substring matches as installed", () => {
|
||||
process.env.CLINE_DIR = mkdtempSync(
|
||||
join(tmpdir(), "cline-marketplace-test-"),
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
name: "goal-helper",
|
||||
path: "/workspace/.cline/plugins/goal-helper/index.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("skips invalid marketplace entries during installed-status checks", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "broken-mcp",
|
||||
type: "mcp",
|
||||
name: "Broken MCP",
|
||||
install: {
|
||||
args: [
|
||||
"broken-mcp",
|
||||
"--transport",
|
||||
"ws",
|
||||
"https://example.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("rejects invalid marketplace entries before spawning commands", async () => {
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "bad",
|
||||
type: "skill",
|
||||
install: { args: [] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("marketplace install args are required");
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the marketplace catalog through the server helper", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ version: 1, entries: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
|
||||
version: 1,
|
||||
entries: [],
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://cline.github.io/marketplace/catalog.json",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces marketplace catalog upstream failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response("nope", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
|
||||
"Failed to fetch marketplace catalog: 503 Service Unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user