Compare commits

..

17 Commits

Author SHA1 Message Date
abeatrix 4532f3f750 Format generated models.dev catalog output 2026-06-29 19:20:50 -07:00
abeatrix dcf4b9ad1e Preserve provider picker options from generated catalog 2026-06-29 19:00:02 -07:00
abeatrix 76d4ea9964 Fix models.dev live catalog normalization 2026-06-29 18:47:36 -07:00
abeatrix f7d2890d5a Use models.dev catalog in legacy extension 2026-06-29 18:29:34 -07:00
Saoud Rizwan 1cbfc7197e chore(vscode): bump to 4.0.4 for legacy release 2026-06-29 13:15:03 -07:00
Tomás Barreiro 4d8f9ea1a3 Remove feature flag from the extension (#11987)
* Remove feature flag from the extension

* fix(vscode): preserve ClinePass provider metadata

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-29 13:07:42 -07:00
Saoud Rizwan 40b0b0c236 chore(vscode): bump to 4.0.3 for legacy release 2026-06-29 10:20:41 -07:00
Tomás Barreiro 8c5e1289bf Remove handler flag (#11978) 2026-06-29 10:15:45 -07:00
Saoud Rizwan 4cddde58ed chore(vscode): bump to 4.0.2 for legacy release 2026-06-28 20:54:04 -07:00
Saoud Rizwan 9d45accc3b fix: default focus chain settings in webview state (#11960)
* fix: default focus chain settings in webview state

* fix: default missing global settings from state manager

* Revert "fix: default missing global settings from state manager"

This reverts commit 76a628689e.
2026-06-28 20:52:52 -07:00
Saoud Rizwan 57760c1e20 fix: polish ClinePass and Z AI model metadata (#11958) 2026-06-28 20:11:11 -07:00
Tomás Barreiro fc33e8dbd9 Fix webview env replacing (#11955) 2026-06-28 18:37:02 -07:00
Saoud Rizwan b1dcaf576a fix(vscode): align ClinePass model resolution and reasoning controls (#11954)
* fix(vscode): show reasoning effort for ClinePass models

* fix(vscode): centralize reasoning effort support

* fix(vscode): normalize Cline recommended Z.ai ids

* fix(vscode): preserve Claude budget reasoning include flag

* fix(vscode): simplify ClinePass reasoning support

* fix(vscode): simplify reasoning effort model detection

* chore(vscode): remove unused ClinePass helper

* chore(vscode): avoid mutating ClinePass model info
2026-06-28 18:17:15 -07:00
Saoud Rizwan 419f2cea73 feat(vscode): improve ClinePass provider UX (#11950)
* feat(vscode): improve ClinePass provider UX

* fix(vscode): handle ClinePass action fallback
2026-06-28 16:33:44 -07:00
Saoud Rizwan 54d8c695a3 fix(vscode): prefer canonical Cline Z.ai model ids (#11951)
* fix(vscode): prefer canonical Cline Z.ai model ids

* fix(vscode): avoid redundant Cline model alias filtering
2026-06-28 16:13:04 -07:00
Saoud Rizwan 5a62ab8564 feat(deepseek): support reasoning effort (incl. xhigh) for thinking models (#11938)
* feat(deepseek): support reasoning effort (incl. xhigh) for thinking models

* fix(deepseek): don't forward 'none' reasoning effort to API

* fix(deepseek): scope reasoning effort selector
2026-06-28 10:55:26 -07:00
Saoud Rizwan f81afb51b5 chore(vscode): bump to 4.0.1 for legacy rollback release 2026-06-27 19:09:05 -07:00
1399 changed files with 191476 additions and 75182 deletions
+2 -2
View File
@@ -41,11 +41,11 @@ fi
# Install project dependencies
echo "Installing dependencies..."
bun run install:all
npm run install:all
# Generate gRPC/protobuf types (required for TypeScript)
echo "Generating proto types..."
bun run protos
npm run protos
echo ""
echo "Session setup complete!"
-55
View File
@@ -1,55 +0,0 @@
# 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.
-128
View File
@@ -1,128 +0,0 @@
# 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.
+90 -93
View File
@@ -13,56 +13,11 @@ 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
- 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`).
- 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`).
- 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.
@@ -73,7 +28,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 `bun run protos`** after any proto changes—generates types in:
**Run `npm 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
@@ -93,6 +48,93 @@ 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.
@@ -109,7 +151,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 `bun run protos`
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm 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.
@@ -157,48 +199,3 @@ 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.
+1 -1
View File
@@ -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
bun run protos
npm 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.
-26
View File
@@ -1,26 +0,0 @@
# 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.
+1 -1
View File
@@ -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.
**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`).
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
Commit with message format: `v{VERSION} Release Notes (hotfix)`
+6 -6
View File
@@ -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**: `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`.
- **Build**: `npm run compile` — NOT `npm run build`.
- **Watch**: `npm run watch` (extension + webview).
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
## Protobuf RPC Workflow (4 steps)
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
2. **Generate**: `bun run protos`.
2. **Generate**: `npm run protos`.
3. **Backend handler**: `src/core/controller/<domain>/`.
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
@@ -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 bun run test:unit`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
## Modifying System Prompt
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
+1 -1
View File
@@ -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 (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
### Screenshots
@@ -1,294 +0,0 @@
name: ext-vscode-publish-legacy
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
# stays the path for releasing main once the SDK migration is solid.
#
# This workflow lives on and is dispatched from `main` (so it satisfies the
# default-branch dispatch requirement), but it checks out and builds the
# `legacy-extension` branch.
on:
workflow_dispatch:
inputs:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "release"
type: choice
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
permissions:
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
cancel-in-progress: false
jobs:
# Gate the publish on the legacy branch's own npm-based test suite. We can't
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
# bun-based suite and it would test main, not the legacy branch — so the
# essential quality + test steps are inlined against the checked-out legacy
# branch.
test:
name: Test Legacy Extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
publish:
needs: test
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: ${{ github.event.inputs.branch }}
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
TAG="v$VERSION"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
exit 1
fi
TAG_REF="refs/tags/$TAG"
HEAD_SHA=$(git rev-parse HEAD)
if git show-ref --verify --quiet "$TAG_REF"; then
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
exit 1
fi
echo "Tag '$TAG' already exists at branch head. Continuing."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$HEAD_SHA"
git push origin "$TAG_REF"
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Verify Tag Matches Package Version
run: |
TAG="${{ steps.resolve_tag.outputs.tag }}"
VERSION="v${{ steps.get_version.outputs.version }}"
if [[ "$TAG" != "$VERSION" ]]; then
echo "Error: tag '$TAG' does not match package version '$VERSION'"
exit 1
fi
echo "Tag and package version match: $TAG"
- 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: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
run: |
# Swap README.marketplace.md into README.md so both the GitHub
# release artifact (vsce package below) and the marketplace
# publish (npm run publish:marketplace below, which swaps
# internally as an idempotent no-op) ship the same README.
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"
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm 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:
tag_name: ${{ steps.resolve_tag.outputs.tag }}
files: "apps/vscode/*.vsix"
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
@@ -31,9 +31,6 @@ 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
@@ -53,47 +50,21 @@ 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
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode ci --include=optional
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: bun run build:sdk
run: npm --prefix apps/vscode/webview-ui ci --include=optional
- 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
@@ -111,9 +82,7 @@ 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 }}
# 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
run: npm run publish:marketplace:nightly
- name: Tag published commit
working-directory: ${{ github.workspace }}
+27 -108
View File
@@ -27,10 +27,6 @@ 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
@@ -106,61 +102,26 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
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."
echo "Using existing tag '$TAG'."
fi
git checkout --detach "$TAG_REF^{commit}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup 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
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode install --include=optional
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: bun run build:sdk
run: npm --prefix apps/vscode/webview-ui install --include=optional
- 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
@@ -180,60 +141,6 @@ 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 }}
@@ -257,23 +164,35 @@ jobs:
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# 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"
# Required to generate the .vsix
vsce package --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
bun run publish:marketplace:prerelease
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
bun run publish:marketplace
npm 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:
+27 -44
View File
@@ -45,16 +45,12 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.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/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/playwright*.ts'
@@ -88,20 +84,26 @@ jobs:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: bun-cache
id: root-cache
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
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') }}
# Cache VS Code installation
- name: Cache VS Code
@@ -122,41 +124,22 @@ jobs:
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before building/packaging the extension for E2E.
- name: Build SDK packages
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: bun run build:sdk
run: npm --prefix apps/vscode/webview-ui ci
- 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 vsce
run: npm install -g @vscode/vsce
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
@@ -165,11 +148,11 @@ jobs:
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a bun run test:e2e:optimal
run: xvfb-run -a npm run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
run: npm run test:e2e:optimal
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
+59 -122
View File
@@ -45,16 +45,13 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.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/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.nycrc*.json'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
@@ -63,13 +60,9 @@ 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'
# 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/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/esbuild.mjs'
@@ -89,38 +82,27 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @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
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
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"
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (Parallel)
run: bun run ci:check-all
run: npm run ci:check-all
vscode-test:
needs: [detect-changes, quality-checks]
@@ -141,43 +123,30 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling/testing the extension.
- name: Build SDK packages
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: bun run build:sdk
run: npm --prefix apps/vscode/webview-ui ci
- name: Assert better-sqlite3 native binary present
- name: Set up NPM on Windows
if: runner.os == 'Windows'
working-directory: ${{ github.workspace }}
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: 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.
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
- name: Cache VS Code test runtime
if: runner.os == 'Windows'
@@ -189,51 +158,24 @@ jobs:
# Build the extension and tests (without redundant checks)
- name: Build Tests and Extension
id: build_step
run: bun run ci:build
run: npm run ci:build
- 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
- name: Unit Tests with coverage - 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: |
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
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
- name: Unit Tests (bun) - Non-Linux
- name: Unit Tests - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
bun run test:unit
npm 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 bun run test:coverage
run: xvfb-run -a npm run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
@@ -241,7 +183,7 @@ jobs:
run: |
for attempt in 1 2 3; do
echo "Running extension integration tests (attempt ${attempt}/3)"
if bun run test:integration; then
if npm run test:integration; then
exit 0
fi
@@ -259,7 +201,7 @@ jobs:
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
bun run test:coverage
npm run test:coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
@@ -268,6 +210,7 @@ jobs:
with:
name: pr-coverage-reports
path: |
apps/vscode/coverage-unit/lcov.info
apps/vscode/webview-ui/coverage/lcov.info
test-platform-integration:
@@ -281,45 +224,39 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
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
# 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
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling the standalone core.
- name: Build SDK packages
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
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"
run: npm --prefix apps/vscode/webview-ui ci
- name: Download ripgrep binaries
run: bun run download-ripgrep
run: npm run download-ripgrep
- name: Compile Standalone
run: bun run compile-standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci
- name: Running testing platform integration spec tests
timeout-minutes: 7
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
-4
View File
@@ -13,9 +13,6 @@ tmp
pnpm-lock.yaml
.clineignore
.cline/enterprise
.cline/remote-config
**/.cline/remote-config
.venv
.actrc
CLAUDE.local.md
@@ -84,4 +81,3 @@ apps/vscode/webview-ui/src/**/*.js.map
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
+1 -2
View File
@@ -7,5 +7,4 @@ fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
cd apps/vscode && bunx lint-staged
lint-staged
+5 -2
View File
@@ -126,7 +126,10 @@
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "bun",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/apps/vscode/.env",
"env": {
@@ -180,7 +183,7 @@
"name": "Open Storybook",
"type": "node",
"request": "launch",
"runtimeExecutable": "bun",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"storybook"
+1 -14
View File
@@ -22,24 +22,11 @@
},
// 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
+12 -32
View File
@@ -6,7 +6,7 @@
{
"label": "compile-standalone",
"type": "shell",
"command": "bun run compile-standalone",
"command": "npm run compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -19,7 +19,7 @@
{
"label": "npm: protos",
"type": "shell",
"command": "bun run protos",
"command": "npm run protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -65,7 +65,7 @@
},
{
"type": "shell",
"command": "bun run build:webview",
"command": "npm run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -86,7 +86,7 @@
},
{
"type": "shell",
"command": "bun run build:webview:test",
"command": "npm run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -108,7 +108,7 @@
},
{
"type": "shell",
"command": "bun run dev:webview",
"command": "npm run dev:webview",
"group": "build",
"problemMatcher": [
{
@@ -145,7 +145,7 @@
},
{
"type": "shell",
"command": "bun run watch:esbuild",
"command": "npm run watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -169,8 +169,7 @@
"isBackground": true,
"label": "npm: watch:esbuild",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -185,7 +184,7 @@
},
{
"type": "shell",
"command": "bun run watch:esbuild:test",
"command": "npm run watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -209,8 +208,7 @@
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -226,7 +224,7 @@
},
{
"type": "shell",
"command": "bun run watch:tsc",
"command": "npm run watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -244,7 +242,7 @@
},
{
"type": "shell",
"command": "bun run watch-tests",
"command": "npm run watch-tests",
"label": "npm: watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -284,7 +282,7 @@
},
{
"type": "shell",
"command": "bun run storybook",
"command": "npm run storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
@@ -313,24 +311,6 @@
"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": [
+25 -32
View File
@@ -1,43 +1,36 @@
# 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.
## [4.0.4]
### 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.
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### 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.
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
+2
View File
@@ -0,0 +1,2 @@
@.clinerules/general.md
@.clinerules/network.md
+14 -14
View File
@@ -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 && bun run install:all && cd ../..
cd apps/vscode && npm 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 && bun run test` to run tests locally.
- Before submitting PR, run `bun run format:fix` to format your code
- Run `cd apps/vscode && npm run test` to run tests locally.
- Before submitting PR, run `npm 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 `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 `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 → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- **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
- **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
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 `bun run lint` to check code style
- Run `bun run format` to automatically format code
- Run `npm run lint` to check code style
- Run `npm 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 `bun test` to ensure all tests pass
- Run `npm 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
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
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
```
- **Writing E2E tests:**
-46
View File
@@ -1,51 +1,5 @@
# Cline CLI Changelog
## 3.0.34
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
- Improved the wording of the ClinePass onboarding step.
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
## 3.0.33
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
- Hide the ClinePass promo for users who already have a ClinePass subscription
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
## 3.0.32
- Improved the ClinePass onboarding experience
- Added an intermediate step before going to ClinePass model selection
- Made the ClinePass subscription screen selectable
- Promoted ClinePass in the startup notice
- Used "ClinePass" as one word consistently and refined the provider UI copy
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
## 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
+1 -1
View File
@@ -121,7 +121,7 @@ const result = await Bun.build({
},
env: "OTEL_*",
banner:
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
});
if (result.logs.length > 0) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.34",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+4 -130
View File
@@ -1,27 +1,5 @@
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,
};
}),
};
});
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
@@ -110,52 +88,6 @@ describe("mcp install command", () => {
).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);
@@ -192,11 +124,11 @@ describe("mcp install command", () => {
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.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("checks for TTY before validating wizard install arguments", async () => {
it("checks for TTY before validating install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
@@ -207,65 +139,7 @@ describe("mcp install command", () => {
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
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"],
},
});
});
});
+6 -45
View File
@@ -1,36 +1,21 @@
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 {
export interface McpInstallOptions {
name: string;
targetArgs?: string[];
transport?: string;
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"] {
): McpAddDefaults["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
@@ -106,18 +91,6 @@ export function buildMcpInstallDefaults(options: {
};
}
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({
@@ -131,23 +104,11 @@ 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.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
}
const defaults = buildMcpInstallDefaults(options);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
.option("-c, --cwd <path>", "Working directory")
.option(
"--thinking <level>",
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
)
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
.option(
@@ -101,7 +101,7 @@ describe("buildConnectorStartRequest", () => {
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: true,
isClinePassEnabled: false,
});
});
+3 -1
View File
@@ -16,6 +16,7 @@ import {
import type { CliLoggerAdapter } from "../logging/adapter";
import { resolveSystemPrompt } from "../runtime/prompt";
import { resolveCliSessionMetadata } from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
parseLocalRowMetadata,
@@ -63,7 +64,8 @@ export async function buildConnectorStartRequest(input: {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
input.options.provider?.trim() ||
+16 -37
View File
@@ -1,10 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import open from "open";
import { useCallback, useMemo, useState } from "react";
import { palette } from "../tui/palette";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import type { CliMigrationNotice } from "./notice";
export function MigrationNoticeContent(
@@ -13,29 +10,10 @@ export function MigrationNoticeContent(
},
) {
const { dialogId, notice, resolve } = props;
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
const openSubscriptionPage = useCallback(() => {
setStatus("Opening ClinePass in your browser...");
void open(subscriptionUrl, { wait: false })
.then(() => {
setStatus("Opened ClinePass in your browser.");
})
.catch(() => {
setStatus(
"Could not open the browser automatically. Use the URL below.",
);
});
}, [subscriptionUrl]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
resolve(true);
return;
}
if (key.name === "return" || key.name === "enter") {
openSubscriptionPage();
}
}, dialogId);
@@ -44,24 +22,25 @@ export function MigrationNoticeContent(
<text fg={palette.act}>{notice.title}</text>
<box flexDirection="column">
<text selectable>
ClinePass is a $9.99/month subscription plan to get access to the
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
more:{" "}
<a href="https://github.com/cline/cline">
<span fg={palette.act}>https://github.com/cline/cline</span>
</a>
</text>
<text selectable>Try it now with a limited-time promo for $1.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
<text selectable>
Running{" "}
<span fg="#98c379" bg="#1f2937">
{" cline "}
</span>{" "}
now opens the terminal UI. To open Kanban, use /quit and run{" "}
<span fg="#98c379" bg="#1f2937">
{" cline kanban "}
</span>{" "}
in your terminal
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Open ClinePass</text>
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
<text fg={palette.muted}>Press Esc to close</text>
</box>
);
}
+9 -71
View File
@@ -1,18 +1,11 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getClineCliMigrationNotice,
markClineCliMigrationNoticeShown,
resolveCliNoticeStatePath,
shouldSuppressClineCliMigrationNoticeForActiveProvider,
} from "./notice";
const tempDirs: string[] = [];
@@ -33,25 +26,8 @@ describe("migration notice", () => {
it("returns the notice for a fresh data dir", () => {
const dataDir = createTempDataDir();
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
});
it("shows when only the old Kanban notice was marked as shown", () => {
const dataDir = createTempDataDir();
const noticePath = resolveCliNoticeStatePath(dataDir);
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
writeFileSync(
noticePath,
`${JSON.stringify(
{ shown: { "cline-cli-tui-default": true } },
null,
2,
)}\n`,
"utf8",
);
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
"cline-cli-cline-pass-intro",
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
"Welcome to the new Cline CLI",
);
});
@@ -70,7 +46,7 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
CLINE_FORCE_MIGRATION_NOTICE: "1",
}),
).toBeDefined();
});
@@ -80,56 +56,18 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
}),
).toBeUndefined();
});
it("does not show when ClinePass is already the active provider", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(
dataDir,
{},
{ activeProviderId: "cline-pass" },
),
).toBeUndefined();
});
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
expect(
shouldSuppressClineCliMigrationNoticeForActiveProvider(" cline-pass "),
).toBe(true);
});
it("does not suppress the active ClinePass provider when forced", () => {
expect(
shouldSuppressClineCliMigrationNoticeForActiveProvider("cline-pass", {
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
}),
).toBe(false);
});
it("shows for the active ClinePass provider when forced", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(
dataDir,
{ CLINE_FORCE_CLINE_PASS_NOTICE: "1" },
{ activeProviderId: "cline-pass" },
),
).toBeDefined();
});
it("shows when forced even if disabled through the environment", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
CLINE_FORCE_MIGRATION_NOTICE: "1",
}),
).toBeDefined();
});
@@ -140,7 +78,7 @@ describe("migration notice", () => {
markClineCliMigrationNoticeShown(dataDir);
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain("cline-cli-cline-pass-intro");
expect(rawState).toContain("cline-cli-tui-default");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
});
+5 -31
View File
@@ -2,19 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
const NOTICE_ID = "cline-cli-cline-pass-intro";
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
const NOTICE_ID = "cline-cli-tui-default";
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
export interface CliMigrationNotice {
id: string;
title: string;
}
export interface CliMigrationNoticeOptions {
activeProviderId?: string;
}
interface CliNoticeState {
shown: Record<string, boolean>;
}
@@ -53,19 +49,6 @@ function readNoticeState(filePath: string): CliNoticeState {
return { shown };
}
function isForceNoticeEnabled(env: NodeJS.ProcessEnv): boolean {
return env[FORCE_NOTICE_ENV]?.trim() === "1";
}
export function shouldSuppressClineCliMigrationNoticeForActiveProvider(
activeProviderId: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return (
activeProviderId?.trim() === "cline-pass" && !isForceNoticeEnabled(env)
);
}
export function resolveCliNoticeStatePath(
dataDir = resolveClineDataDir(),
): string {
@@ -75,29 +58,20 @@ export function resolveCliNoticeStatePath(
export function getClineCliMigrationNotice(
dataDir = resolveClineDataDir(),
env: NodeJS.ProcessEnv = process.env,
options: CliMigrationNoticeOptions = {},
): CliMigrationNotice | undefined {
const noticePath = resolveCliNoticeStatePath(dataDir);
const noticeState = readNoticeState(noticePath);
const forceNotice = isForceNoticeEnabled(env);
const forceNotice = env[FORCE_NOTICE_ENV]?.trim() === "1";
const disableNotice = env[DISABLE_NOTICE_ENV]?.trim() === "1";
if (disableNotice && !forceNotice) {
return undefined;
}
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(
options.activeProviderId,
env,
)
) {
return undefined;
}
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
return undefined;
}
return {
id: NOTICE_ID,
title: "Try ClinePass",
title: "Welcome to the new Cline CLI",
};
}
+44 -190
View File
@@ -1,9 +1,6 @@
import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
CliMigrationNoticeOptions,
} from "./kanban-migration/notice";
import type { CliMigrationNotice } from "./kanban-migration/notice";
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
const fsActual = vi.hoisted(() => ({
@@ -62,13 +59,9 @@ const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<
(
dataDir?: string,
env?: NodeJS.ProcessEnv,
options?: CliMigrationNoticeOptions,
) => CliMigrationNotice | undefined
>(() => undefined),
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
() => undefined,
),
markClineCliMigrationNoticeShown: vi.fn(),
}));
const updateMocks = vi.hoisted(() => ({
@@ -122,7 +115,6 @@ const telemetryMocks = vi.hoisted(() => ({
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
refreshCliFeatureFlagsInBackground: vi.fn(),
}));
function forcePromptModeInput() {
@@ -187,8 +179,7 @@ vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground:
featureFlagMocks.refreshCliFeatureFlagsInBackground,
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
@@ -267,7 +258,6 @@ describe("runCli lightweight command dispatch", () => {
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
featureFlagMocks.refreshCliFeatureFlagsInBackground.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -417,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", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -427,30 +417,6 @@ 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")
@@ -464,7 +430,7 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: hello world",
"Unknown command or extra arguments: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
@@ -508,7 +474,7 @@ describe("runCli lightweight command dispatch", () => {
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
const { runCli } = await import("./main");
@@ -517,7 +483,7 @@ describe("runCli lightweight command dispatch", () => {
cwd: process.cwd(),
});
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
cwd: "/tmp/cline-worktree",
workspaceRoot: "/tmp/cline-worktree",
@@ -640,8 +606,8 @@ describe("runCli lightweight command dispatch", () => {
it("passes the migration notice marker into interactive mode", async () => {
const notice = {
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
id: "cline-cli-tui-default",
title: "Welcome to the new Cline CLI",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
Object.defineProperty(process.stdout, "isTTY", {
@@ -672,37 +638,6 @@ describe("runCli lightweight command dispatch", () => {
).toHaveBeenCalledTimes(1);
});
it("passes the active ClinePass provider into the migration notice gate", async () => {
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
provider: "cline-pass",
model: "cline-pass/test-model",
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
migrationNoticeMocks.getClineCliMigrationNotice,
).toHaveBeenCalledWith(undefined, process.env, {
activeProviderId: "cline-pass",
});
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "cline-pass",
}),
expect.anything(),
undefined,
expect.objectContaining({
initialNotice: undefined,
}),
);
});
it("does not start OAuth before onboarding in interactive mode", async () => {
authMocks.isOAuthProvider.mockReturnValue(true);
authMocks.normalizeProviderId.mockReturnValue("cline");
@@ -792,7 +727,7 @@ describe("runCli lightweight command dispatch", () => {
it("uses the bundled catalog path for single-prompt runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -983,7 +918,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
@@ -1002,14 +937,11 @@ describe("runCli lightweight command dispatch", () => {
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
// The account identity must be seeded before flags are refreshed/used so
// the background refresh resolves flags for the correct account.
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
.invocationCallOrder[0],
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
@@ -1081,30 +1013,12 @@ describe("runCli lightweight command dispatch", () => {
it("skips hub prewarm for yolo runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
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(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
@@ -1128,12 +1042,12 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("rejects /team without quoted task text", async () => {
it("shows /team usage in single-prompt mode when no task is provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team"];
@@ -1141,10 +1055,9 @@ 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(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: /team"),
expect(stdoutWrite).toHaveBeenCalledWith(
expect.stringContaining("Usage: /team <task description>"),
);
});
@@ -1153,14 +1066,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1169,40 +1082,19 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("leaves thinking unset when --thinking is not provided", async () => {
it("leaves thinking disabled when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "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: 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",
"hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1216,14 +1108,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1246,14 +1138,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1262,32 +1154,6 @@ 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();
@@ -1298,14 +1164,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "low",
@@ -1319,13 +1185,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1341,19 +1207,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"basic",
"say hello",
];
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1369,19 +1229,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"agentic",
"say hello",
];
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1431,13 +1285,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: false,
@@ -1476,7 +1330,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1484,7 +1338,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
@@ -1503,7 +1357,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1511,7 +1365,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
+29 -46
View File
@@ -20,6 +20,7 @@ import {
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
@@ -41,7 +42,6 @@ import {
isOAuthProvider,
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -112,23 +112,6 @@ 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();
@@ -421,24 +404,15 @@ export async function runCli(): Promise<void> {
"--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,
});
});
@@ -748,6 +722,13 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
if (program.args.length > 1) {
writeErr(
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
process.exitCode = 1;
return;
}
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
@@ -834,13 +815,6 @@ 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 =
@@ -955,7 +929,8 @@ export async function runCli(): Promise<void> {
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
@@ -1023,12 +998,19 @@ export async function runCli(): Promise<void> {
);
}
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const resolvedReasoning = resolveCliReasoning({
thinking: args.thinking,
thinkingExplicitlySet: args.thinkingExplicitlySet,
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
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 { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1064,8 +1046,11 @@ export async function runCli(): Promise<void> {
sandbox: sandboxEnabled,
sandboxDataDir,
verbose: args.verbose,
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
thinking: effectiveReasoningEffort !== "none",
reasoningEffort:
effectiveReasoningEffort === "none"
? undefined
: effectiveReasoningEffort,
outputMode: args.outputMode,
mode: args.mode,
logger: loggerAdapter.core,
@@ -1180,9 +1165,7 @@ export async function runCli(): Promise<void> {
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
activeProviderId: provider,
});
initialNotice = getClineCliMigrationNotice();
if (initialNotice) {
markInitialNoticeShown = () => {
markClineCliMigrationNoticeShown();
@@ -365,48 +365,6 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
new SessionNotFoundError("session-1"),
);
const runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
initialMessages: [],
}),
);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
const manager = makeManager();
let runtime!: ReturnType<typeof makeRuntime>;
manager.readMessages.mockImplementationOnce(async () => {
await runtime.restartEmpty();
return [
{
role: "user" as const,
content: [{ type: "text" as const, text: "stale" }],
},
];
});
runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
const manager = makeManager();
const recoveryRead = deferred<Message[]>();
@@ -49,13 +49,6 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type CurrentMessagesRead =
| { messages: Message[]; status: "read" }
| { messages: Message[]; status: "recovered" }
| { messages: Message[]; status: "stale" };
type MissingSessionRecovery = {
messages: Message[];
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
@@ -110,9 +103,7 @@ export function createInteractiveSessionRuntime(input: {
let shutdownRequested = false;
let activeSessionId = "";
let abortRequested = false;
let missingSessionRecoveryPromise:
| Promise<MissingSessionRecovery>
| undefined;
let missingSessionRecoveryPromise: Promise<void> | undefined;
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
@@ -284,34 +275,14 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
return { messages: [], status: "read" };
}
try {
const messages = (await manager.readMessages(sessionId)) ?? [];
return {
messages,
status: activeSessionId === sessionId ? "read" : "stale",
};
} catch (error) {
if (
abortRequested ||
shutdownRequested ||
!isSessionNotFoundError(error)
) {
throw error;
}
const recovery = await recoverMissingActiveSession(error);
return { messages: recovery.messages, status: "recovered" };
const readCurrentMessages = async (): Promise<Message[]> => {
if (!sessionManager || !activeSessionId) {
return [];
}
return (await sessionManager.readMessages(activeSessionId)) ?? [];
};
const recoverMissingActiveSession = async (
error: unknown,
): Promise<MissingSessionRecovery> => {
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
if (missingSessionRecoveryPromise) {
return await missingSessionRecoveryPromise;
}
@@ -319,7 +290,7 @@ export function createInteractiveSessionRuntime(input: {
const manager = sessionManager;
const missingSessionId = activeSessionId;
if (!manager || !missingSessionId || shutdownRequested) {
return { messages: [] };
return;
}
const messages = await manager
.readMessages(missingSessionId)
@@ -336,7 +307,6 @@ export function createInteractiveSessionRuntime(input: {
startupError = undefined;
clearActiveSession();
await startFreshSession(messages);
return { messages };
})().finally(() => {
missingSessionRecoveryPromise = undefined;
});
@@ -391,13 +361,7 @@ export function createInteractiveSessionRuntime(input: {
};
const restartWithCurrentMessages = async (): Promise<void> => {
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
// If reading recovered a missing hub session, the current messages are
// already in the replacement session. If the read is stale, another async
// operation changed the active session while this read was in flight.
return;
}
const messages = await readCurrentMessages();
await restartWithMessages(messages);
};
@@ -546,13 +510,7 @@ export function createInteractiveSessionRuntime(input: {
if (!sessionManager) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const { messages, status } = await readCurrentMessages();
if (status === "stale" || (status === "recovered" && !activeSessionId)) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
// If reading messages recovered the session, `messages` are the same messages
// used to seed the replacement session, so it is safe to compact the current
// active session with them.
const messages = await readCurrentMessages();
const messagesBefore = messages.length;
if (messagesBefore === 0) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
@@ -593,10 +551,7 @@ export function createInteractiveSessionRuntime(input: {
return undefined;
}
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
return undefined;
}
const messages = await readCurrentMessages();
return { messages, checkpointHistory };
};
+5 -71
View File
@@ -39,10 +39,7 @@ const sessionEventsMocks = vi.hoisted(() => ({
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_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 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";
@@ -552,7 +549,7 @@ describe("runAgent", () => {
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
@@ -580,7 +577,7 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
@@ -658,7 +655,7 @@ describe("runAgent", () => {
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
@@ -702,73 +699,10 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
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");
+1 -3
View File
@@ -204,9 +204,7 @@ export async function runAgent(
(!event.recoverable || config.verbose) &&
event.error.message.trim()
) {
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message).trim(),
);
displayedErrorMessages.add(event.error.message.trim());
}
handleEvent(event, config);
};
@@ -1,40 +0,0 @@
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" });
});
});
+4 -27
View File
@@ -8,7 +8,6 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
@@ -58,23 +57,6 @@ 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,
@@ -389,7 +371,7 @@ export async function runInteractive(
? async () => {
try {
await sessionRuntime.ensureReady();
const { messages } = await sessionRuntime.readCurrentMessages();
const messages = await sessionRuntime.readCurrentMessages();
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
@@ -428,12 +410,6 @@ 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,
@@ -661,11 +637,12 @@ export async function runInteractive(
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
reasoning: config.reasoningEffort
? { enabled: true, effort: config.reasoningEffort }
: { enabled: false },
});
await sessionRuntime.restartWithCurrentMessages();
},
+3 -8
View File
@@ -3,11 +3,7 @@ import { CLINE_BIN } from "./helpers/constants.js";
import { clineEnv } from "./helpers/env.js";
import { expectVisible } from "./helpers/terminal.js";
// 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 };
const HELP_TERMINAL = { columns: 120, rows: 50 };
// ===========================================================================
// Root-level flag descriptions
@@ -27,11 +23,10 @@ test.describe("root flag descriptions", () => {
"verbose output",
"Working directory",
"Configuration directory",
"Set reasoning effort:",
"Bare --thinking uses medium",
"omitted leaves provider default",
"Set reasoning effort level",
"consecutive mistakes",
"Output messages as JSON",
"ACP",
"Check for updates and install if available",
"Run the kanban app",
]);
+1 -1
View File
@@ -124,7 +124,7 @@ export function clineEnv(
}),
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
NO_UPDATE_NOTIFIER: "1",
CLINE_NO_AUTO_UPDATE: "1",
...extra,
-60
View File
@@ -12,8 +12,6 @@ const coreMocks = vi.hoisted(() => {
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
fetchAvailableSubscriptionPlans: vi.fn(),
fetchCurrentUserPlan: vi.fn(),
serviceOptions,
};
});
@@ -41,14 +39,6 @@ vi.mock("@cline/core", async (importOriginal) => {
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
fetchAvailableSubscriptionPlans(input?: {
type?: "individual" | "teams";
}) {
return coreMocks.fetchAvailableSubscriptionPlans(input);
}
fetchCurrentUserPlan() {
return coreMocks.fetchCurrentUserPlan();
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -110,8 +100,6 @@ describe("createClineAccountService", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -208,8 +196,6 @@ describe("loadClineAccountSnapshot", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -263,49 +249,3 @@ 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.fetchCurrentUserPlan.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);
});
});
+1 -58
View File
@@ -2,8 +2,6 @@ import {
type ClineAccountBalance,
type ClineAccountOrganization,
type ClineAccountOrganizationBalance,
type ClineSubscriptionPlan,
type UserCurrentPlan,
ClineAccountService,
type ClineAccountUser,
formatProviderOAuthApiKey,
@@ -126,9 +124,8 @@ export async function createClineAccountService(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
providerSettingsManager?: ProviderSettingsManager;
}): Promise<ClineAccountService | undefined> {
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
const manager = new ProviderSettingsManager();
const settings =
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
const apiBaseUrl = resolveAccountApiBaseUrl({
@@ -206,60 +203,6 @@ 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" });
}
export async function loadCurrentUserPlan(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadCurrentUserPlanFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
+15 -91
View File
@@ -1,12 +1,10 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
@@ -17,7 +15,6 @@ import {
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getModeInputBackground,
palette,
type TerminalTheme,
@@ -269,8 +266,7 @@ function ToolCallView(props: {
);
}
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -285,96 +281,39 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
<text
fg={props.defaultFg}
selectable
content={
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
}
content="You have run out of Cline credits. Add credits in the dashboard to continue."
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg="gray">Dashboard: </text>
<text fg="cyan" selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Switch to ClinePass: </text>
<text fg="gray">
type /settings in CLI and switch provider to ClinePass
</text>
</box>
</box>
</box>
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
}
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
const planAccent = getModeAccent("plan", props.terminalTheme);
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]);
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
return (
<box flexDirection="row">
<text fg={planAccent} content="* " />
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={planAccent}
borderColor="yellow"
paddingX={1}
>
<text fg={planAccent}>ClinePass subscription required</text>
<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) => (
<text key={feature} fg={props.defaultFg} selectable>
<span fg="green"> </span>
<span>{feature}</span>
</text>
))}
</box>
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
@@ -394,21 +333,18 @@ function ClinePassSubscriptionErrorView(props: {
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const planAccent = getModeAccent("plan", props.terminalTheme);
return (
<box flexDirection="row">
<text fg={planAccent} content="* " />
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={planAccent}
borderColor="yellow"
paddingX={1}
>
<text fg={planAccent}>Personal ClinePass required</text>
<text fg="yellow">Personal ClinePass required</text>
<text
fg={props.defaultFg}
selectable
@@ -422,7 +358,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
@@ -520,22 +455,11 @@ export function ChatEntryView(props: {
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
);
}
if (isClinePassSubscriptionError(entry.text)) {
return (
<ClinePassSubscriptionErrorView
defaultFg={defaultFg}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
@@ -1,5 +1,5 @@
import "opentui-spinner/react";
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
import type { AgentMode } from "@cline/core";
import type { ScrollBoxRenderable } from "@opentui/core";
import {
forwardRef,
@@ -21,7 +21,6 @@ export interface TranscriptScrollHandle {
interface ChatMessageListProps {
entries: ChatEntry[];
isStreaming?: boolean;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
uiMode?: AgentMode;
}
@@ -101,9 +100,6 @@ export const ChatMessageList = forwardRef<
key={key}
entry={entry}
accent={accent}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
@@ -1,13 +0,0 @@
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
const url = new URL(
CLINE_PASS_SUBSCRIPTION_PATH,
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
return url.toString();
}
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("keeps the configured app base URL", () => {
expect(
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
).toBe(
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
);
});
});
@@ -37,7 +37,6 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -249,33 +248,18 @@ export function ProviderPickerContent(
);
}
export type ExistingProviderAction =
| "use_existing"
| "reconfigure"
| "open_subscription_page"
| "open_usage_billing";
export interface ExistingProviderOption {
value: ExistingProviderAction;
label: string;
onSelect?: () => Promise<void> | void;
}
export type ExistingProviderAction = "use_existing" | "reconfigure";
export function UseExistingOrReconfigureContent(
props: ChoiceContext<ExistingProviderOption> & {
props: ChoiceContext<ExistingProviderAction> & {
providerName: string;
extraOptions?: ExistingProviderOption[];
},
) {
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 { resolve, dismiss, dialogId, providerName } = props;
const options: { value: ExistingProviderAction; label: string }[] = [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
];
const [selected, setSelected] = useState(0);
useDialogKeyboard((key) => {
@@ -285,7 +269,7 @@ export function UseExistingOrReconfigureContent(
}
if (key.name === "return" || key.name === "enter") {
const opt = options[selected];
if (opt) resolve(opt);
if (opt) resolve(opt.value);
return;
}
if (key.name === "up" || (key.ctrl && key.name === "p")) {
@@ -330,86 +314,6 @@ export function UseExistingOrReconfigureContent(
);
}
function ClinePassBrowserPageContent(
props: ChoiceContext<boolean> & {
providerName: string;
pageLabel: string;
url: string;
openedStatus: string;
},
) {
const {
resolve,
dismiss,
dialogId,
providerName,
pageLabel,
url,
openedStatus,
} = props;
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
void open(url, { wait: false })
.then(() => {
setStatus(openedStatus);
})
.catch(() => {
setStatus("Could not open browser automatically. Open the URL below.");
});
}, [url, openedStatus]);
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">{pageLabel}:</text>
<text fg="cyan" selectable>
<a href={url}>{url}</a>
</text>
<text fg="gray">
<em>Enter or Esc to go back</em>
</text>
</box>
);
}
export function ClinePassSubscriptionContent(
props: ChoiceContext<boolean> & {
providerName: string;
},
) {
const subscriptionUrl = useMemo(
() =>
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
[],
);
return (
<ClinePassBrowserPageContent
{...props}
pageLabel="Subscription page"
url={subscriptionUrl}
openedStatus="Opened subscription page in your browser."
/>
);
}
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
+4 -31
View File
@@ -3,7 +3,6 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -56,44 +55,18 @@ describe("formatStatusBarUsageText", () => {
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline",
showCost: true,
}),
).toBe("(12,345 tokens) $0.12");
});
it("displays subscription message when the provider is a subscription provider", () => {
it("omits cost when usage cost is hidden", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline-pass",
showCost: false,
}),
).toBe("(12,345 tokens) $0.00 (included with subscription)");
});
});
describe("resolveModelDisplayName", () => {
it("keeps ClinePass visible when model ids have provider prefixes", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("ClinePass/glm-5.2");
});
it("uses the friendly model name for non-ClinePass providers", () => {
expect(
resolveModelDisplayName({
providerId: "cline",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("GLM 5.2");
).toBe("(12,345 tokens)");
});
});
+9 -33
View File
@@ -1,9 +1,6 @@
import type { AgentMode } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
@@ -49,31 +46,14 @@ function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with subscription)";
}
if (!shouldShowCliUsageCost(providerId)) {
return "";
}
return formatCost(totalCost);
}
export function formatStatusBarUsageText(input: {
totalTokens: number;
totalCost: number;
providerId: string;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
return tokens;
}
return `${tokens} ${costText}`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
@@ -94,22 +74,17 @@ function lookupModelInfo(
}
export function resolveModelDisplayName(config: {
providerId?: string;
modelId: string;
knownModels?: Record<string, unknown>;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
const displayName =
config.providerId === "cline-pass"
? `ClinePass/${modelIdTail}`
: (info?.name ?? modelIdTail);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
if (config.thinking && config.reasoningEffort) {
return `${displayName} (${config.reasoningEffort})`;
return `${name} (${config.reasoningEffort})`;
}
return displayName;
return name;
}
export function resolveModelMaxInputTokens(config: {
@@ -177,6 +152,7 @@ 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).
@@ -193,7 +169,7 @@ export function StatusBar(props: StatusBarProps) {
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
providerId: props.providerId,
showCost: showUsageCost,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
+8 -58
View File
@@ -18,9 +18,8 @@ import {
import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
type ExistingProviderAction,
OAuthLoginContent,
ProviderConfigInputContent,
ProviderPickerContent,
@@ -79,36 +78,6 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
dialog: DialogActions;
termHeight: number;
}): ExistingProviderOption[] {
if (input.providerId !== "cline-pass") {
return [];
}
return [
{
value: "open_subscription_page",
label: "Manage subscription & see usage",
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(
dialog: DialogActions,
config: Config,
@@ -133,33 +102,14 @@ async function runProviderChange(
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
const extraOptions = providerToExistingProviderOptions({
providerId: newProviderId,
providerName: displayName,
dialog,
termHeight,
const action = await dialog.choice<ExistingProviderAction>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
),
});
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 (!action) return false;
needsAuth = action === "reconfigure";
}
if (needsAuth) {
+1 -10
View File
@@ -10,7 +10,6 @@ import {
useDialogState,
} from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
import type { RepoStatus } from "../utils/repo-status";
import { readRepoStatus } from "../utils/repo-status";
@@ -542,17 +541,10 @@ function App(props: TuiProps) {
const notice = props.initialNotice;
const onInitialNoticeShown = props.onInitialNoticeShown;
const currentProviderId = props.config.providerId;
useEffect(() => {
if (!notice) return;
if (initialNoticeShownRef.current) return;
if (appView !== "home") return;
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
) {
initialNoticeShownRef.current = true;
return;
}
initialNoticeShownRef.current = true;
const timeout = setTimeout(() => {
@@ -568,7 +560,7 @@ function App(props: TuiProps) {
});
}, 0);
return () => clearTimeout(timeout);
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
}, [appView, dialog, notice, onInitialNoticeShown]);
const {
appendEntry: appendSessionEntry,
@@ -887,7 +879,6 @@ function App(props: TuiProps) {
repoStatus,
textareaRef: promptInput.textareaRef,
transcriptScrollRef,
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
queuedPrompts,
selectedQueuedPromptId,
editingQueuedPrompt,
-2
View File
@@ -2,7 +2,6 @@ import type {
AgentEvent,
AgentMode,
CheckpointEntry,
ClineSubscriptionPlan,
TeamEvent,
} from "@cline/core";
import type {
@@ -130,7 +129,6 @@ 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,
-2
View File
@@ -50,7 +50,6 @@ export function ChatView(props: {
};
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
autocomplete?: AutocompleteDropdownProps;
queuedPrompts?: QueuedPromptItem[];
selectedQueuedPromptId?: string | null;
@@ -90,7 +89,6 @@ export function ChatView(props: {
ref={props.transcriptScrollRef}
entries={session.entries}
isStreaming={session.isStreaming}
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
uiMode={session.uiMode}
/>
+7 -150
View File
@@ -10,24 +10,16 @@ import {
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import open from "open";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getCliSubscriptionUrl,
getIndividualPlanFeatures,
} from "../../../utils/cline-pass-errors";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
loadCurrentUserPlanFromProviderSettings,
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildClineModelEntries,
type ClineModelPickerEntry,
@@ -56,8 +48,6 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
@@ -88,7 +78,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const menuOptions = useMemo(
() =>
getMainMenuOptions({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
}),
[],
);
@@ -159,19 +150,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [modelsDefaultId, setModelsDefaultId] = useState("");
const [customModelId, setCustomModelId] = useState("");
const [customModelError, setCustomModelError] = useState("");
const [clinePassSubscriptionStatus, setClinePassSubscriptionStatus] =
useState<ClinePassSubscriptionStatus>("loading");
const [clinePassSubscriptionError, setClinePassSubscriptionError] =
useState("");
const [clinePassCurrentPlanName, setClinePassCurrentPlanName] = useState("");
const [clinePassPlanFeatures, setClinePassPlanFeatures] = useState<string[]>(
[],
);
const [clinePassSubscriptionSelected, setClinePassSubscriptionSelected] =
useState(0);
const [clinePassSubscriptionOpenStatus, setClinePassSubscriptionOpenStatus] =
useState("");
const clinePassSubscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const modelItems: SearchableItem[] = useMemo(
() =>
@@ -287,62 +265,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providerSettingsManager],
);
const refreshClinePassSubscriptionStatus = useCallback(() => {
setClinePassSubscriptionStatus("loading");
setClinePassSubscriptionError("");
setClinePassCurrentPlanName("");
setClinePassSubscriptionOpenStatus("");
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((currentPlanResult) =>
loadIndividualSubscriptionPlansFromProviderSettings({
providerSettingsManager,
})
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((availablePlansResult) => ({
availablePlansResult,
currentPlanResult,
})),
)
.then(({ currentPlanResult, availablePlansResult }) => {
if (availablePlansResult.status === "fulfilled") {
setClinePassPlanFeatures(
getIndividualPlanFeatures(availablePlansResult.value),
);
}
if (currentPlanResult.status === "rejected") {
const error = currentPlanResult.reason;
const message =
error instanceof Error ? error.message : String(error);
if (message.trim().toLowerCase() === "no plan found for user") {
setClinePassSubscriptionStatus("unsubscribed");
return;
}
setClinePassSubscriptionError(message);
setClinePassSubscriptionStatus("error");
return;
}
const plan = currentPlanResult.value?.plan;
if (plan) {
setClinePassCurrentPlanName(
plan.displayName || plan.name || plan.id || "ClinePass",
);
setClinePassSubscriptionStatus("subscribed");
} else {
setClinePassSubscriptionStatus("unsubscribed");
}
});
}, [providerSettingsManager]);
const transitionToModelPicker = useCallback(
(providerId: string) => {
setActiveProviderId(providerId);
@@ -366,27 +288,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providers, loadModelsForProvider, providerSettingsManager],
);
const transitionToClinePassSubscription = useCallback(() => {
setActiveProviderId("cline-pass");
const provider = providers.find((p) => p.id === "cline-pass");
setActiveProviderName(provider?.name ?? "ClinePass");
setModelsDefaultId(provider?.defaultModelId ?? "");
setClinePassSubscriptionSelected(0);
setStep("cline_pass_subscription");
refreshClinePassSubscriptionStatus();
}, [providers, refreshClinePassSubscriptionStatus]);
const handleAuthComplete = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline-pass") {
transitionToClinePassSubscription();
return;
}
transitionToModelPicker(providerId);
},
[transitionToClinePassSubscription, transitionToModelPicker],
);
const resetAuth = useCallback(() => {
setAuthStatus("");
setAuthUrl("");
@@ -412,11 +313,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setVerifyUrl: setDeviceVerifyUrl,
setStatus: setDeviceStatus,
setError: setDeviceError,
onComplete: handleAuthComplete,
onComplete: transitionToModelPicker,
telemetry: getCliTelemetryService(),
});
},
[providerSettingsManager, handleAuthComplete],
[providerSettingsManager, transitionToModelPicker],
);
const startOAuthFlow = useCallback(
@@ -438,46 +339,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setStatus: setAuthStatus,
setAuthUrl,
setError: setAuthError,
onComplete: handleAuthComplete,
onComplete: transitionToModelPicker,
telemetry: getCliTelemetryService(),
});
},
[
providerSettingsManager,
resetAuth,
handleAuthComplete,
transitionToModelPicker,
startDeviceCodeFlow,
],
);
const continueFromClinePassSubscription = useCallback(() => {
transitionToModelPicker("cline-pass");
}, [transitionToModelPicker]);
const openClinePassSubscriptionPage = useCallback(() => {
setClinePassSubscriptionOpenStatus("Opening subscription page...");
void open(clinePassSubscriptionUrl, { wait: false })
.then(() => {
setClinePassSubscriptionOpenStatus(
"Opened subscription page in your browser.",
);
})
.catch(() => {
setClinePassSubscriptionOpenStatus(
`Could not open browser automatically. Open ${clinePassSubscriptionUrl}`,
);
});
}, [clinePassSubscriptionUrl]);
useEffect(() => {
if (
step === "cline_pass_subscription" &&
clinePassSubscriptionStatus === "subscribed"
) {
transitionToModelPicker("cline-pass");
}
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
const refreshCodexCliStatus = useCallback(() => {
setCodexCliStatus(undefined);
setCodexCliChecking(true);
@@ -759,9 +632,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
modelList,
clineEntries,
clineModelSelected,
clinePassSubscriptionStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
thinkingSelected,
setStep,
setMenuSelected,
@@ -778,11 +648,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setDeviceError,
setDeviceStatus,
setClineModelSelected,
setClinePassSubscriptionSelected,
setThinkingSelected,
continueFromClinePassSubscription,
refreshClinePassSubscriptionStatus,
openClinePassSubscriptionPage,
abortOAuth: () => {
authAbortRef.current = true;
},
@@ -804,7 +670,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
return {
activeProviderName,
activeProviderId,
authError,
authStatus,
authUrl,
@@ -817,14 +682,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
clineEntries,
clineKnownModels,
clineModelSelected,
clinePassCurrentPlanName,
clinePassPlanFeatures,
clinePassSubscriptionError,
clinePassSubscriptionOpenStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
clinePassSubscriptionStatus,
clinePassSubscriptionUrl,
deviceError,
deviceStatus,
deviceUserCode,
@@ -9,8 +9,6 @@ import {
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
@@ -28,9 +26,6 @@ export function useOnboardingKeyboard(input: {
modelList: SearchableListState;
clineEntries: ClineModelPickerEntry[];
clineModelSelected: number;
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
clinePassSubscriptionSelected: number;
thinkingSelected: number;
setStep: (step: OnboardingStep) => void;
setMenuSelected: Dispatch<SetStateAction<number>>;
@@ -43,11 +38,7 @@ export function useOnboardingKeyboard(input: {
setDeviceError: (value: string) => void;
setDeviceStatus: (value: string) => void;
setClineModelSelected: Dispatch<SetStateAction<number>>;
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
setThinkingSelected: Dispatch<SetStateAction<number>>;
continueFromClinePassSubscription: () => void;
refreshClinePassSubscriptionStatus: () => void;
openClinePassSubscriptionPage: () => void;
abortOAuth: () => void;
abortDeviceCode: () => void;
resetAuth: () => void;
@@ -102,11 +93,6 @@ export function useOnboardingKeyboard(input: {
input.setStep("byo_provider");
return;
}
if (input.step === "cline_pass_subscription") {
input.setStep("menu");
input.setMenuSelected(0);
return;
}
if (input.step === "cline_model") {
input.setStep("menu");
input.setMenuSelected(0);
@@ -149,43 +135,6 @@ export function useOnboardingKeyboard(input: {
if (input.step === "device_code") return;
if (input.step === "cline_pass_subscription") {
const total = input.clinePassSubscriptionOptions.length;
if (total === 0) return;
if (key.name === "up" || (key.ctrl && key.name === "p")) {
input.setClinePassSubscriptionSelected((s) =>
s <= 0 ? total - 1 : s - 1,
);
return;
}
if (key.name === "down" || (key.ctrl && key.name === "n")) {
input.setClinePassSubscriptionSelected((s) =>
s >= total - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return" || key.name === "enter") {
const option =
input.clinePassSubscriptionOptions[
Math.min(input.clinePassSubscriptionSelected, total - 1)
];
if (!option) return;
if (option.value === "subscribe") {
input.openClinePassSubscriptionPage();
} else if (option.value === "refresh") {
if (input.clinePassSubscriptionStatus !== "loading") {
input.refreshClinePassSubscriptionStatus();
}
} else if (option.value === "skip") {
input.continueFromClinePassSubscription();
} else if (option.value === "back") {
input.setStep("menu");
input.setMenuSelected(0);
}
}
return;
}
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) =>
@@ -8,7 +8,6 @@ export type OnboardingStep =
| "byo_provider"
| "byo_apikey"
| "codex_cli_setup"
| "cline_pass_subscription"
| "cline_model"
| "model_picker"
| "custom_model_id"
@@ -37,17 +36,6 @@ export interface MenuOption {
icon: string;
}
export type ClinePassSubscriptionAction =
| "subscribe"
| "refresh"
| "skip"
| "back";
export interface ClinePassSubscriptionOption {
value: ClinePassSubscriptionAction;
label: string;
}
export const MAIN_MENU: MenuOption[] = [
{
label: "Sign in with Cline",
@@ -83,25 +71,6 @@ export function getMainMenuOptions(options?: {
);
}
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
{
value: "subscribe",
label: "Subscribe to ClinePass",
},
{
value: "refresh",
label: "Re-check subscription status",
},
{
value: "skip",
label: "Skip for now",
},
{
value: "back",
label: "Go back",
},
];
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -127,12 +96,6 @@ export interface ModelEntry {
supportsReasoning: boolean;
}
export type ClinePassSubscriptionStatus =
| "loading"
| "subscribed"
| "unsubscribed"
| "error";
export interface ProviderCatalogItem {
id: string;
name: string;
+3 -209
View File
@@ -1,7 +1,5 @@
import "opentui-spinner/react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
@@ -19,18 +17,10 @@ import {
TrackedRobot,
type useMouseTracker,
} from "../../components/tracked-robot";
import {
useTerminalBackground,
useTerminalTheme,
} from "../../hooks/use-terminal-background";
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
THINKING_LEVELS,
} from "./model";
import { type MenuOption, THINKING_LEVELS } from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -39,10 +29,6 @@ function useDefaultFg(): string | undefined {
return getDefaultForeground(terminalBg);
}
function getClinePassSubscriptionOptionId(index: number): string {
return `cline-pass-subscription-option-${index}`;
}
interface OnboardingFrameProps {
children: ReactNode;
compact: boolean;
@@ -482,198 +468,6 @@ export function OnboardingClineModelScreen(props: {
);
}
export function OnboardingClinePassSubscriptionScreen(props: {
compact: boolean;
contentWidth: number;
currentPlanName: string;
error: string;
mouse: MouseTrackerState;
openStatus: string;
options: ClinePassSubscriptionOption[];
planFeatures: string[];
selected: number;
status: ClinePassSubscriptionStatus;
subscriptionUrl: string;
}) {
const defaultFg = useDefaultFg();
const terminalTheme = useTerminalTheme();
const planAccent = getModeAccent("plan", terminalTheme);
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const isLoading = props.status === "loading";
const isSubscribed = props.status === "subscribed";
const isError = props.status === "error";
const bodyHeight = props.compact ? 17 : 19;
useEffect(() => {
if (isSubscribed) {
return;
}
const scrollSelectedOptionIntoView = () => {
scrollRef.current?.scrollChildIntoView(
getClinePassSubscriptionOptionId(props.selected),
);
};
scrollSelectedOptionIntoView();
queueMicrotask(scrollSelectedOptionIntoView);
const timeout = setTimeout(scrollSelectedOptionIntoView, 0);
return () => clearTimeout(timeout);
}, [isSubscribed, props.selected]);
return (
<OnboardingFrame
compact={props.compact}
contentWidth={props.contentWidth}
mouse={props.mouse}
>
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={isSubscribed ? palette.success : planAccent}
paddingX={1}
paddingY={1}
height={bodyHeight}
overflow="hidden"
>
<scrollbox
ref={scrollRef}
width="100%"
height="100%"
scrollY
scrollX={false}
viewportOptions={{ overflow: "hidden" }}
contentOptions={{ flexDirection: "column" }}
>
<box flexDirection="column" width="100%" flexShrink={0}>
<text
fg={isSubscribed ? palette.success : planAccent}
flexShrink={0}
>
{isSubscribed
? "ClinePass subscription active"
: "ClinePass subscription required"}
</text>
{isLoading ? (
<box flexDirection="row" gap={1} flexShrink={0}>
<spinner name="dots" color="gray" />
<text fg="gray">Checking your ClinePass subscription...</text>
</box>
) : isSubscribed ? (
<text fg={defaultFg} selectable flexShrink={0}>
Current plan: {props.currentPlanName || "ClinePass"}
</text>
) : isError ? (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="Could not verify your ClinePass subscription. Re-check before choosing a ClinePass model."
/>
) : (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
)}
{props.status === "error" &&
props.error &&
props.error !== "no plan found for user" && (
<text fg="red" selectable flexShrink={0}>
{props.error}
</text>
)}
{!isSubscribed && props.planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
{props.planFeatures.map((feature) => {
if (
feature === "Low cost subscription pricing" ||
feature === "Generous limits and reliable access" ||
feature === "Built for as many programmers as possible"
) {
return null;
}
return (
<text
key={feature}
fg={defaultFg}
selectable
flexShrink={0}
>
<span fg="green"> </span>
<span>{feature}</span>
</text>
);
})}
</box>
)}
{!isSubscribed && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
{props.options.map((option, i) => {
const isSel = i === props.selected;
return (
<box
id={getClinePassSubscriptionOptionId(i)}
key={option.value}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
height={1}
flexShrink={0}
overflow="hidden"
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
flexShrink={0}
>
{option.label}
</text>
</box>
);
})}
</box>
)}
{props.openStatus && (
<text fg="gray" selectable flexShrink={0}>
{props.openStatus}
</text>
)}
{!isSubscribed && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
<text fg="gray" flexShrink={0}>
If the browser button does not work:
</text>
<text fg={palette.act} selectable flexShrink={0}>
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
</text>
</box>
)}
</box>
</scrollbox>
</box>
<text fg="gray" paddingX={1}>
<em>/ navigate, Enter to select, Esc to go back, Ctrl+C to exit</em>
</text>
</OnboardingFrame>
);
}
export function OnboardingModelPickerScreen(props: {
activeProviderName: string;
compact: boolean;
@@ -6,7 +6,6 @@ import { useOnboardingController } from "./controller";
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
import {
OnboardingClineModelScreen,
OnboardingClinePassSubscriptionScreen,
OnboardingCodexCliScreen,
OnboardingCustomModelIdScreen,
OnboardingDeviceCodeScreen,
@@ -122,24 +121,6 @@ export function OnboardingView(props: OnboardingViewProps) {
);
}
if (state.step === "cline_pass_subscription") {
return (
<OnboardingClinePassSubscriptionScreen
compact={compact}
contentWidth={contentWidth}
currentPlanName={state.clinePassCurrentPlanName}
error={state.clinePassSubscriptionError}
mouse={mouse}
openStatus={state.clinePassSubscriptionOpenStatus}
options={state.clinePassSubscriptionOptions}
planFeatures={state.clinePassPlanFeatures}
selected={state.clinePassSubscriptionSelected}
status={state.clinePassSubscriptionStatus}
subscriptionUrl={state.clinePassSubscriptionUrl}
/>
);
}
if (state.step === "model_picker") {
return (
<OnboardingModelPickerScreen
+4 -9
View File
@@ -1,9 +1,8 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -16,18 +15,14 @@ describe("cline-pass-errors", () => {
),
).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);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
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",
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
+5 -26
View File
@@ -1,34 +1,16 @@
import {
type ClineSubscriptionPlan,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
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()}`;
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
return planWithFeatures?.features?.included ?? [];
}
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
@@ -77,9 +59,6 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
getBooleanFlagEnabled: vi.fn(() => true),
}));
vi.mock("@cline/core", async (importOriginal) => {
@@ -12,13 +13,20 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("./feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
}),
}));
describe("listLocalProviders", () => {
it("enables ClinePass when listing the SDK provider list", async () => {
it("passes the ClinePass feature flag into the SDK provider list", async () => {
const { listLocalProviders } = await import("./provider-catalog");
const manager = {} as never;
await listLocalProviders(manager);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
isClinePassEnabled: true,
});
+3 -1
View File
@@ -2,11 +2,13 @@ import {
listLocalProviders as internalListLocalProviders,
type ProviderSettingsManager,
} from "@cline/core";
import { getCliFeatureFlagsService } from "./feature-flags";
export async function listLocalProviders(
manager: ProviderSettingsManager,
): ReturnType<typeof internalListLocalProviders> {
return await internalListLocalProviders(manager, {
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
}
-89
View File
@@ -1,89 +0,0 @@
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",
});
});
});
-65
View File
@@ -1,65 +0,0 @@
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 };
}
+1 -1
View File
@@ -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;
-6
View File
@@ -3,9 +3,3 @@ 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";
}
-11
View File
@@ -57,17 +57,6 @@ 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"'),
+66 -68
View File
@@ -1,9 +1,8 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import {
type McpServerOAuthState,
McpSettingsUpdateSkippedError,
resolveDefaultMcpSettingsPath,
updateMcpSettingsFileSync,
} from "@cline/core";
export interface McpServerEntry {
@@ -57,6 +56,28 @@ 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,
@@ -71,85 +92,62 @@ function getOwnServerRecord(
return value as Record<string, unknown>;
}
/**
* 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;
});
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`,
);
}
export function addServer(name: string, transport: McpTransport): void {
mutateServers((servers) => {
servers[name] = { transport };
});
const servers = readRawServers();
servers[name] = { transport };
writeServers(servers);
}
export function removeServer(name: string): boolean {
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;
}
const servers = readRawServers();
if (!(name in servers)) return false;
delete servers[name];
writeServers(servers);
return true;
}
export function updateServer(name: string, transport: McpTransport): void {
mutateServers((servers) => {
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
servers[name] = { ...existing, transport };
});
const servers = readRawServers();
const existing =
servers[name] && typeof servers[name] === "object"
? (servers[name] as Record<string, unknown>)
: {};
servers[name] = { ...existing, transport };
writeServers(servers);
}
export function clearServerOAuth(name: string): void {
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;
const servers = readRawServers();
const existing = getOwnServerRecord(servers, name);
if (!existing) {
return;
}
delete existing.oauth;
servers[name] = existing;
writeServers(servers);
}
export function toggleServer(name: string, disabled: boolean): void {
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;
});
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);
}
+28 -112
View File
@@ -51,53 +51,18 @@ describe("marketplace installer", () => {
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", () => {
it("maps remote MCP catalog args to the hub MCP upsert 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,
});
});
@@ -318,6 +283,8 @@ describe("marketplace installer", () => {
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
@@ -381,7 +348,7 @@ describe("marketplace installer", () => {
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",
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
@@ -403,16 +370,13 @@ describe("marketplace installer", () => {
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");
@@ -501,78 +465,19 @@ describe("marketplace installer", () => {
]);
});
it("runs MCP installs through the current Cline CLI without prompts", async () => {
it("runs official plugin uninstalls 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({
name: "context7",
status: "installed",
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
headers: {
Authorization: "Bearer token",
},
},
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
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(
{
@@ -590,8 +495,12 @@ describe("marketplace installer", () => {
message: "Uninstalled Goal.",
});
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
@@ -635,12 +544,15 @@ describe("marketplace installer", () => {
});
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");
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
@@ -668,8 +580,12 @@ describe("marketplace installer", () => {
},
);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
+120 -97
View File
@@ -8,7 +8,7 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir as osHomedir, platform } from "node:os";
import { homedir, platform } from "node:os";
import {
basename,
dirname,
@@ -18,15 +18,16 @@ import {
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import {
deleteMcpServer,
readMcpServersResponse,
upsertMcpServer,
} from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
@@ -89,12 +90,6 @@ const MARKETPLACE_CATALOG_URL =
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
const SECRET_KEY_VALUE_PATTERN =
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
const SECRET_BEARER_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
const SECRET_AUTHORIZATION_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
@@ -275,10 +270,11 @@ function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi,
"$1[redacted]",
)
.replace(/\b(Bearer)\s+\S+/gi, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
@@ -378,7 +374,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const headers: Record<string, string> = {};
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
@@ -394,40 +389,11 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
index++;
continue;
}
const shouldParseHeader =
parsingMarketplaceOptions ||
normalizeTransport(transportType) !== "stdio";
if (
shouldParseHeader &&
(arg === "--header" || arg?.startsWith("--header="))
) {
const rawHeader =
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
if (!rawHeader) throw new Error("--header requires a value");
const separatorIndex = rawHeader.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
const headerName = rawHeader.slice(0, separatorIndex).trim();
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
if (!headerName || !headerValue) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
headers[headerName] = headerValue;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
if (Object.keys(headers).length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
@@ -449,7 +415,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
name,
transportType,
url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
disabled: false,
};
}
@@ -605,12 +570,6 @@ function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
return Boolean(installPath && existsSync(installPath));
}
function resolveHomeDir(): string {
return (
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
);
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
@@ -647,12 +606,12 @@ function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
const skillsDir = join(homedir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
@@ -795,6 +754,50 @@ async function installSkill(
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
@@ -845,6 +848,47 @@ async function installPlugin(
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
@@ -852,38 +896,14 @@ export async function installMarketplaceEntry(
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = upsertMcpServer(input);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
message: `Installed ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
@@ -901,21 +921,24 @@ export async function uninstallMarketplaceEntry(
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function installMarketplaceEntryFromCatalog(
+27 -33
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
@@ -65,9 +65,9 @@ export function readMcpServersResponse(): JsonRecord {
}
export function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
const path = resolveMcpSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
}
export function ensureMcpSettingsFile(): string {
@@ -78,21 +78,23 @@ export function ensureMcpSettingsFile(): string {
return path;
}
function readServersMap(): { path: string; servers: JsonRecord } {
const path = ensureMcpSettingsFile();
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
return { path, servers: (parsed.mcpServers as JsonRecord | undefined) ?? {} };
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
// Hold the cross-process lock across read-modify-write so a concurrent writer
// (the extension, the CLI) cannot clobber this change.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
settings.mcpServers = servers;
});
const { servers } = readServersMap();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
writeMcpServersMap(servers);
return readMcpServersResponse();
}
@@ -125,27 +127,19 @@ export function upsertMcpServer(input: JsonRecord): JsonRecord {
},
disabled: input.disabled === true,
};
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot clobber this upsert.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
const { servers } = readServersMap();
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
writeMcpServersMap(servers);
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot resurrect the deleted server from a stale snapshot.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
delete servers[name];
settings.mcpServers = servers;
});
const { servers } = readServersMap();
delete servers[name];
writeMcpServersMap(servers);
return readMcpServersResponse();
}
+28 -30
View File
@@ -1,9 +1,11 @@
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
@@ -43,7 +45,6 @@ import {
setDisabledPlugin,
setDisabledTools,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
@@ -142,9 +143,9 @@ function readMcpServersResponse(): JsonRecord {
}
function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
const path = resolveMcpSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
}
function ensureMcpSettingsFile(): string {
@@ -1042,19 +1043,18 @@ export async function handleCommand(
}
if (command === "set_mcp_server_disabled") {
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = {
...(current as JsonRecord),
disabled: Boolean(args?.disabled),
};
settings.mcpServers = servers;
});
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
const name = String(args?.name ?? "").trim();
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = {
...(current as JsonRecord),
disabled: Boolean(args?.disabled),
};
writeMcpServersMap(servers);
return readMcpServersResponse();
}
if (command === "upsert_mcp_server") {
@@ -1093,23 +1093,21 @@ export async function handleCommand(
metadata: input.metadata,
};
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
writeMcpServersMap(servers);
return readMcpServersResponse();
}
if (command === "delete_mcp_server") {
const path = ensureMcpSettingsFile();
updateMcpSettingsFileSync(path, (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ?? {}) as JsonRecord;
delete servers[String(args?.name ?? "")];
settings.mcpServers = servers;
});
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
const servers = (parsed.mcpServers as JsonRecord | undefined) ?? {};
delete servers[String(args?.name ?? "")];
writeMcpServersMap(servers);
return readMcpServersResponse();
}
if (command === "ensure_mcp_settings_file") {
+16
View File
@@ -0,0 +1,16 @@
{
"extension": [
"ts"
],
"spec": [
"src/**/__tests__/*.ts",
"src/test/services/**/*.test.ts"
],
"require": [
"ts-node/register",
"source-map-support/register",
"./src/test/requires.ts"
],
"recursive": true,
"exit": true
}
+48
View File
@@ -0,0 +1,48 @@
{
"all": true,
"check-coverage": false,
"reporter": [
"text",
"lcov"
],
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.d.ts",
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
"**/__tests__/**",
"**/test/**",
"**/tests/**",
"**/.nyc_output/**",
"**/.vscode-test/**",
"**/tests-results/**",
"src/test/**",
"src/generated/**",
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/build/**",
"**/coverage/**",
"**/coverage-unit/**",
"**/proto/**",
"**/*.{config,setup}.{js,ts,mjs,cjs}",
"**/vite-env.d.ts",
"**/*.{css,scss,sass,less,styl}",
"**/*.{svg,png,jpg,jpeg,gif,ico}",
"**/*.{json,yaml,yml}"
],
"extension": [
".ts",
".js"
],
"cache": true,
"sourceMap": true,
"instrument": true,
"report-dir": "./coverage-unit"
}
+1 -12
View File
@@ -1,20 +1,9 @@
import { defineConfig } from "@vscode/test-cli"
import path from "path"
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
export default defineConfig({
files: [
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
// The bun unit suite (src/**/__tests__/* and src/test/services/**) runs under
// `bun test` (run-bun-unit-tests.ts) and imports `bun:test`, which this
// Node-based runner cannot load. Exclude it here.
"!out/src/**/__tests__/**/*.test.js",
"!out/src/test/services/**/*.test.js",
"!src/**/__tests__/**/*.test.js",
"!src/test/services/**/*.test.js",
],
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
mocha: {
ui: "bdd",
timeout: 20000, // Maximum time (in ms) that a test can run before failing
+1 -22
View File
@@ -5,31 +5,13 @@
# Agent tooling, never shipped in the VSIX
.agents/**
.claude/**
.cline/**
.codex/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
# Nested workspace-member node_modules (bun links these under each package).
# Scoped to the sub-package dirs so it doesn't shadow the top-level
# node_modules/@vscode/codicons re-include below.
webview-ui/node_modules/**
testing-platform/node_modules/**
standalone/**/node_modules/**
src/**
standalone/**
# Build/dev tooling and inputs — bundled into dist/extension.js, not needed in the VSIX.
bunfig.toml
esbuild.mjs
knip.json
biome.jsonc
test-setup.js
.env.example
scripts/**
proto/**
testing-platform/**
tests/**
.gitignore
.yarnrc
esbuild.js
@@ -52,7 +34,6 @@ sdk/**
# only exists if a publish aborts mid-swap; neither should ship in the .vsix.
README.marketplace.md
.README.github.bak
package.json.backup
# Custom
**/demo.gif
@@ -65,6 +46,7 @@ eslint-rules/
old_docs/
evals/
.codespellrc
.mocharc.json
buf.yaml
.clinerules/
@@ -96,9 +78,6 @@ old_docs/**
e2e-build.mjs
e2e.vsix
test-results/
coverage/**
webview-ui/coverage/**
webview-ui/.vite-port
# Ignore Storybook files
**/*.stories.tsx
+6 -21
View File
@@ -1,11 +1,6 @@
{
"root": true,
"root": false,
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"defaultBranch": "main"
},
"assist": {
"enabled": true,
"actions": {
@@ -129,18 +124,14 @@
"!!**/playwright",
"!!**/.vscode-test",
"!!**/test-results",
"!!**/coverage",
"!!**/node_modules",
"!!**/webview-ui/build",
"!!**/generated",
"!!**/proto",
"!!**/tests/specs",
"!!assets/icons/*.svg"
"!!**/tests/specs"
]
},
"plugins": [
"src/dev/grit/process-env.grit"
],
"plugins": ["src/dev/grit/process-env.grit"],
"overrides": [
{
"includes": [
@@ -155,15 +146,11 @@
"!!src/integrations/terminal/**",
"!!src/core/controller/ui/openWalkthrough.ts"
],
"plugins": [
"src/dev/grit/vscode-api.grit"
]
"plugins": ["src/dev/grit/vscode-api.grit"]
},
{
// Do not use console logging directly, use the Logger service instead.
"plugins": [
"src/dev/grit/console-log.grit"
],
"plugins": ["src/dev/grit/console-log.grit"],
"includes": [
"**",
"!!**/esbuild.*",
@@ -196,9 +183,7 @@
"!!src/core/storage/utils/state-helpers.ts",
"!!src/extension.ts"
],
"plugins": [
"src/dev/grit/use-cache-service.grit"
]
"plugins": ["src/dev/grit/use-cache-service.grit"]
}
]
}
-7
View File
@@ -1,7 +0,0 @@
[test]
# Module-substitution aliases for `bun test`. bun resolves tsconfig `paths`
# (@/*, @core/*, @shared/*, …) and the real @cline/llms + @cline/shared dist
# builds on its own; the preload only shadows `vscode` and `@cline/core` with
# their unit-test stubs (mirrors vitest.config.ts resolve.alias). See
# src/test/bun-test-preload.ts for details.
preload = ["./src/test/bun-test-preload.ts"]
+39
View File
@@ -85,6 +85,44 @@ const esbuildProblemMatcherPlugin = {
},
}
const copyWasmFiles = {
name: "copy-wasm-files",
setup(build) {
build.onEnd(() => {
// tree sitter
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
const targetDir = path.join(__dirname, destDir)
// Copy tree-sitter.wasm
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
// Copy language-specific WASM files
const languageWasmDir = path.join(__dirname, "node_modules", "tree-sitter-wasms", "out")
const languages = [
"typescript",
"tsx",
"python",
"rust",
"javascript",
"go",
"cpp",
"c",
"c_sharp",
"ruby",
"java",
"php",
"swift",
"kotlin",
]
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(targetDir, filename))
})
})
},
}
const buildEnvVars = {
"import.meta.url": "_importMetaUrl",
"process.env.IS_STANDALONE": JSON.stringify(standalone ? "true" : "false"),
@@ -138,6 +176,7 @@ const baseConfig = {
define: buildEnvVars,
tsconfig: path.resolve(__dirname, "tsconfig.json"),
plugins: [
copyWasmFiles,
aliasResolverPlugin,
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
+21 -32
View File
@@ -1,34 +1,23 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"workspaces": {
".": {
"entry": [
"src/extension.ts",
"src/standalone/cline-core.ts",
"src/generated/hosts/standalone/protobus-server-setup.ts",
"src/generated/hosts/standalone/host-bridge-clients.ts",
"src/generated/hosts/vscode/protobus-services.ts",
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts",
"src/**/*.test.ts",
"src/**/__tests__/**/*.ts",
"src/test/**/*.ts"
],
"project": [
"src/**/*.ts"
]
},
"webview-ui": {
"entry": [
"src/services/grpc-client.ts",
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/**/__tests__/**/*.{ts,tsx}"
],
"project": [
"src/**/*.{ts,tsx}",
"*.ts"
],
"vite": true
}
}
"entry": [
"src/extension.ts",
"src/standalone/cline-core.ts",
"src/generated/hosts/standalone/protobus-server-setup.ts",
"src/generated/hosts/standalone/host-bridge-clients.ts",
"src/generated/hosts/vscode/protobus-services.ts",
"src/generated/hosts/vscode/hostbridge-grpc-service-config.ts"
],
"project": [
"src/**/*.ts"
],
"ignore": [
"out/**",
"node_modules/**",
"*.d.ts",
"**/*.test.ts",
"**/__tests__",
"src/test/**",
"src/shared/**"
],
"vite": true
}
+21850
View File
File diff suppressed because it is too large Load Diff
+112 -74
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.0.0",
"version": "4.0.4",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -42,7 +42,8 @@
"activationEvents": [
"onLanguage",
"onUri",
"onStartupFinished"
"onStartupFinished",
"workspaceContains:evals.env"
],
"main": "./dist/extension.js",
"contributes": {
@@ -88,7 +89,7 @@
{
"id": "mcp",
"title": "Extend with Powerful Tools (MCP)",
"description": "Connect to databases, APIs, and other external tools through MCP.",
"description": "Connect to databases, APIs, or discover new capabilities in the MCP Marketplace.",
"media": {
"markdown": "walkthrough/step4.md"
}
@@ -137,11 +138,6 @@
"title": "MCP Servers",
"icon": "$(server)"
},
{
"command": "cline.marketplaceButtonClicked",
"title": "Customize",
"icon": "$(wrench)"
},
{
"command": "cline.historyButtonClicked",
"title": "History",
@@ -233,9 +229,26 @@
"command": "cline.reconstructTaskHistory",
"title": "Reconstruct Task History",
"category": "Cline"
},
{
"command": "cline.reviewComment.reply",
"title": "Reply",
"category": "Cline",
"enablement": "!commentIsEmpty"
},
{
"command": "cline.reviewComment.addToChat",
"title": "Add to Cline Chat",
"category": "Cline",
"icon": "$(link-external)"
}
],
"keybindings": [
{
"command": "editor.action.submitComment",
"key": "enter",
"when": "commentEditorFocused && commentController == cline-ai-review && !commentIsEmpty"
},
{
"command": "cline.addToChat",
"key": "cmd+'",
@@ -265,7 +278,7 @@
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.marketplaceButtonClicked",
"command": "cline.mcpButtonClicked",
"group": "navigation@2",
"when": "view == claude-dev.SidebarProvider"
},
@@ -337,6 +350,24 @@
{
"command": "cline.abortGitCommitMessage",
"when": "config.git.enabled && cline.isGeneratingCommit"
},
{
"command": "cline.reviewComment.reply",
"when": "false"
}
],
"comments/commentThread/context": [
{
"command": "cline.reviewComment.reply",
"group": "inline",
"when": "commentController == cline-ai-review"
}
],
"comments/commentThread/title": [
{
"command": "cline.reviewComment.addToChat",
"group": "inline",
"when": "commentController == cline-ai-review"
}
]
},
@@ -346,74 +377,67 @@
}
},
"scripts": {
"vscode:prepublish": "bun run package",
"compile": "bun run check-types && bun run lint && bun esbuild.mjs",
"compile-standalone": "bun run check-types && bun run lint && bun esbuild.mjs --standalone",
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"postcompile-standalone": "node scripts/package-standalone.mjs",
"dev": "bun run protos && bun run watch",
"watch": "bun run --parallel watch:esbuild watch:tsc",
"watch:esbuild": "bun esbuild.mjs --watch",
"dev": "npm run protos && npm run watch",
"watch": "npx npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.mjs --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "bun run check-types && bun run build:webview && bun run lint && bun esbuild.mjs --production",
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production",
"protos": "node scripts/build-proto.mjs",
"models": "node scripts/generate-models-dev-catalog.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "bun run clean:build && bun run clean:deps",
"clean:all": "npm run clean:build && npm run clean:deps",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "bun run protos && bunx tsc --noEmit && cd webview-ui && bunx tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && bun run lint:proto",
"check-types": "npm run protos && tsc --noEmit && cd webview-ui && tsc --noEmit",
"lint": "biome lint --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
"lint:proto": "bash ./scripts/proto-lint.sh",
"analyze:unused": "bunx --yes knip --config knip.json --include files,exports,types,enumMembers,duplicates",
"analyze:unused:prod": "bunx --yes knip --config knip.json --production --include files,exports,types,enumMembers,dependencies",
"analyze:unused:fix-exports": "node scripts/remove-unused-exports.mjs --apply",
"analyze:unused:fix-exports:dry": "node scripts/remove-unused-exports.mjs",
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error --semicolons=as-needed",
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write --semicolons=as-needed",
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe --semicolons=as-needed",
"ci:check-all": "bun run --parallel check-types lint format",
"ci:build": "bun run protos && bun run build:webview && bun esbuild.mjs && bun run compile-tests",
"pretest": "bun run compile && bun run compile-tests && bun run compile-standalone && bun run lint",
"test": "bun run test:unit && bun run test:integration",
"test:integration": "bun run compile-tests && vscode-test",
"test:unit": "bun scripts/run-bun-unit-tests.ts",
"test:vitest": "vitest run --config vitest.config.ts",
"test:vitest:watch": "vitest --config vitest.config.ts",
"test:bun": "bun scripts/run-bun-tests.ts",
"test:bun:unit": "bun scripts/run-bun-unit-tests.ts",
"test:coverage": "bun run compile-tests && vscode-test --coverage",
"test:sca-server": "bun --watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "bun scripts/testing-platform-orchestrator.ts",
"dev:mcp-oauth-test-server": "bun src/dev/mcp-oauth-test-server/server.ts",
"format": "biome format --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
"format:fix": "biome check --config-path ./biome.jsonc --changed --since main --no-errors-on-unmatched --files-ignore-unknown=true --write",
"fix:all": "biome check --config-path ./biome.jsonc --no-errors-on-unmatched --files-ignore-unknown=true --write --diagnostic-level=error --unsafe",
"ci:check-all": "npx npm-run-all -p check-types lint format",
"ci:build": "npm run protos && npm run build:webview && node esbuild.mjs && npm run compile-tests",
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
"test": "npx npm-run-all test:unit test:integration",
"test:integration": "vscode-test",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e:build": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
"install:all": "bun install",
"dev:webview": "cd webview-ui && bun run dev",
"build:webview": "bun run protos && cd webview-ui && bun run build",
"test:webview": "cd webview-ui && bun run test",
"test:e2e:build": "vsce package --allow-package-secrets sendgrid --out dist/e2e.vsix",
"test:e2e": "playwright install && npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "npm run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "npx tsx scripts/interactive-playwright.ts",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
"test:webview": "cd webview-ui && npm run test",
"publish:marketplace": "node scripts/publish-marketplace.mjs",
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
"publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs",
"docs": "cd docs && bun run dev",
"docs:check-links": "cd docs && bun run check",
"docs:rename-file": "cd docs && bun run rename",
"prepare": "npx husky",
"docs": "cd docs && npm run dev",
"docs:check-links": "cd docs && npm run check",
"docs:rename-file": "cd docs && npm run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && bun run storybook",
"eval:smoke:run": "bun evals/smoke-tests/run-smoke-tests.ts"
"storybook": "cd webview-ui && npm run storybook",
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
"node scripts/generate-state-proto.mjs",
"git add apps/vscode/proto/cline/state.proto"
"git add proto/cline/state.proto"
],
"*": [
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true --semicolons=as-needed"
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"devDependencies": {
@@ -429,6 +453,7 @@
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/picomatch": "^4.0.2",
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^21.0.0",
@@ -440,41 +465,42 @@
"c8": "^10.1.3",
"chai": "^4.3.10",
"chalk": "5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.0",
"glob": "^11.0.0",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"minimatch": "^3.0.3",
"minimist": "^1.2.8",
"mocha": "^11.7.4",
"playwright": "^1.55.1",
"npm-run-all": "^4.1.5",
"nyc": "^17.1.0",
"prebuild-install": "^7.1.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^21.0.3",
"tar": "^7.5.2",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5",
"vitest": "^4.0.17"
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
"@bufbuild/protobuf": "^2.2.5",
"@cline/agents": "workspace:*",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
"@google/genai": "^1.30.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/proto-loader": "^0.7.13",
"@grpc/reflection": "^1.0.4",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.56.0",
"@opentelemetry/core": "^2.1.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
@@ -494,6 +520,9 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.6.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
"@types/uuid": "^10.0.0",
@@ -519,15 +548,13 @@
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"js-yaml": "^4.1.1",
"jschardet": "^3.1.4",
"json5": "^2.2.3",
"jwt-decode": "^4.0.0",
"mammoth": "^1.11.0",
"nanoid": "^5.1.6",
"nice-grpc": "^2.1.12",
"nice-grpc-common": "^2.0.3",
"node-machine-id": "^1.1.12",
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.21.0",
@@ -546,13 +573,24 @@
"simple-git": "3.36.0",
"strip-ansi": "^7.1.2",
"tailwindcss": "^4.1.14",
"tree-sitter-wasms": "^0.1.11",
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"undici": "^7.26.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"zod": "^4.3.6"
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
},
"overrides": {
"tar-fs": ">=3.1.1",
"tar": "^7.5.2",
"vite": "^7.1.11",
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.8",
"diff": "8.0.4"
},
"c8": {
"reporter": [
+27
View File
@@ -3,13 +3,17 @@ syntax = "proto3";
package cline;
import "cline/common.proto";
import "google/protobuf/timestamp.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
service CheckpointsService {
rpc checkpointDiff(Int64Request) returns (Empty);
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
rpc subscribeToCheckpoints(CheckpointSubscriptionRequest) returns (stream CheckpointEvent);
rpc getCwdHash(StringArrayRequest) returns (PathHashMap);
}
message CheckpointRestoreRequest {
@@ -18,3 +22,26 @@ message CheckpointRestoreRequest {
string restore_type = 3;
optional int64 offset = 4;
}
message CheckpointSubscriptionRequest {
string cwd_hash = 1;
}
message CheckpointEvent {
enum OperationType {
CHECKPOINT_INIT = 0;
CHECKPOINT_COMMIT = 1;
CHECKPOINT_RESTORE = 2;
}
OperationType operation = 1;
string cwd_hash = 2;
bool is_active = 3;
google.protobuf.Timestamp timestamp = 4;
optional string task_id = 5;
optional string commit_hash = 6;
}
message PathHashMap {
map<string, string> path_hash = 1;
}
-106
View File
@@ -1,106 +0,0 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
service MarketplaceService {
rpc getMarketplaceCatalog(EmptyRequest) returns (MarketplaceCatalog);
rpc listMarketplaceLocalInstalledEntries(EmptyRequest) returns (MarketplaceLocalInstalledEntries);
rpc listMarketplaceInstalledEntries(MarketplaceEntriesRequest) returns (MarketplaceInstalledEntries);
rpc installMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc uninstallMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc toggleMarketplaceLocalInstalledEntry(ToggleMarketplaceLocalInstalledEntryRequest) returns (MarketplaceLocalInstalledEntries);
rpc uninstallMarketplaceLocalInstalledEntry(MarketplaceLocalInstalledEntryRequest) returns (MarketplaceInstallResult);
}
message MarketplaceTag {
string label = 1;
optional string color = 2;
}
message MarketplaceCounts {
optional int32 tools = 1;
optional int32 prompts = 2;
optional int32 resources = 3;
}
message MarketplaceEnvVar {
string name = 1;
bool required = 2;
optional string description = 3;
optional string url = 4;
}
message MarketplaceInstall {
repeated string args = 1;
repeated MarketplaceEnvVar env = 2;
optional string command = 3;
optional string notes = 4;
}
message MarketplaceEntry {
string id = 1;
string type = 2;
string name = 3;
optional string tagline = 4;
optional string description = 5;
repeated string tags = 6;
repeated MarketplaceTag tag_objects = 7;
optional string author = 8;
optional string source_url = 9;
optional string homepage_url = 10;
optional MarketplaceCounts counts = 11;
optional MarketplaceInstall install = 12;
}
message MarketplaceCatalog {
repeated MarketplaceEntry entries = 1;
}
message MarketplaceEntriesRequest {
repeated MarketplaceEntry entries = 1;
}
message MarketplaceInstalledEntries {
repeated string installed_keys = 1;
}
message MarketplaceLocalInstalledEntry {
string id = 1;
string type = 2;
string name = 3;
optional string description = 4;
optional string path = 5;
optional string source = 6;
bool enabled = 7;
}
message MarketplaceLocalInstalledEntries {
repeated MarketplaceLocalInstalledEntry entries = 1;
}
message ToggleMarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
bool enabled = 2;
}
message MarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
}
message MarketplaceEntryRequest {
MarketplaceEntry entry = 1;
}
message MarketplaceInstallResult {
string id = 1;
string type = 2;
string status = 3;
string message = 4;
optional string output = 5;
}
+42
View File
@@ -12,11 +12,16 @@ service McpService {
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (McpDownloadResponse);
rpc restartMcpServer(StringRequest) returns (McpServers);
rpc deleteMcpServer(StringRequest) returns (McpServers);
rpc toggleToolAutoApprove(ToggleToolAutoApproveRequest) returns (McpServers);
rpc refreshMcpMarketplace(EmptyRequest) returns (McpMarketplaceCatalog);
rpc openMcpSettings(EmptyRequest) returns (Empty);
rpc authenticateMcpServer(StringRequest) returns (Empty);
// Subscribe to MCP marketplace catalog updates
rpc subscribeToMcpMarketplaceCatalog(EmptyRequest) returns (stream McpMarketplaceCatalog);
rpc getLatestMcpServers(Empty) returns (McpServers);
// Subscribe to MCP server updates
@@ -109,3 +114,40 @@ message McpServer {
message McpServers {
repeated McpServer mcp_servers = 1;
}
message McpMarketplaceItem {
string mcp_id = 1;
string github_url = 2;
string name = 3;
string author = 4;
string description = 5;
string codicon_icon = 6;
string logo_url = 7;
string category = 8;
repeated string tags = 9;
bool requires_api_key = 10;
optional string readme_content = 11;
optional string llms_installation_content = 12;
bool is_recommended = 13;
int32 github_stars = 14;
int32 download_count = 15;
string created_at = 16;
string updated_at = 17;
string last_github_sync = 18;
}
message McpMarketplaceCatalog {
repeated McpMarketplaceItem items = 1;
}
message McpDownloadResponse {
string mcp_id = 1;
string github_url = 2;
string name = 3;
string author = 4;
string description = 5;
string readme_content = 6;
string llms_installation_content = 7;
bool requires_api_key = 8;
optional string error = 9;
}
+53 -152
View File
@@ -21,6 +21,8 @@ service ModelsService {
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns recommended and free Cline models
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
// Refreshes and returns Cline provider models
rpc refreshClineModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
@@ -53,18 +55,6 @@ service ModelsService {
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Vercel AI Gateway models
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Lists providers available from the unified SDK-backed catalog
rpc listProviders(Empty) returns (ProviderListingsResponse);
// Resolves model metadata for a provider through the unified SDK-backed catalog
rpc resolveProviderModels(ResolveProviderModelsRequest) returns (ProviderModelsResponse);
// Resolves model metadata for a provider/model without refreshing model lists
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
// Reads redacted effective provider configuration
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model selection atomically with its model metadata
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
}
// List of VS Code LM models
@@ -127,142 +117,6 @@ message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
// Lightweight provider entry for the top-level model/provider picker.
// Does not include the full model list; use resolveProviderModels for models.
message ProviderListing {
string id = 1;
string name = 2;
optional string default_model_id = 3;
optional string family = 4;
optional string protocol = 5;
optional string auth_description = 6;
optional string base_url_description = 7;
bool allows_custom_model_ids = 8;
// SDK-driven hint for cost display. Values: "show" (default) or "hide".
// Sourced from `resolveProviderUsageCostDisplay(provider.metadata)` in
// `@cline/llms`. When "hide", consumers must suppress per-token pricing
// and total cost displays (matches the CLI's `shouldShowCliUsageCost`).
string usage_cost_display = 11;
}
message ProviderListingsResponse {
repeated ProviderListing providers = 1;
}
message ResolveProviderModelsRequest {
string provider_id = 1;
bool force_refresh = 2;
optional string request_id = 3;
}
message CatalogErrorInfo {
string kind = 1;
string message = 2;
optional string code = 3;
optional bool retryable = 4;
}
message ProviderModelsResponse {
string provider_id = 1;
string request_id = 2;
string config_fingerprint = 3;
int64 fetched_at = 4;
bool ok = 5;
map<string, OpenRouterModelInfo> models = 6;
optional string default_model_id = 7;
optional string source = 8;
optional CatalogErrorInfo error = 9;
}
message ResolveModelInfoRequest {
string provider_id = 1;
optional string model_id = 2;
}
message ResolveModelInfoResponse {
string provider_id = 1;
string model_id = 2;
optional OpenRouterModelInfo model_info = 3;
string source = 4;
}
message AwsProviderConfig {
optional string authentication = 1;
optional string profile = 2;
optional string access_key = 3;
int64 access_key_length = 4;
optional string secret_key = 5;
int64 secret_key_length = 6;
optional string session_token = 7;
int64 session_token_length = 8;
optional string endpoint = 9;
optional bool use_prompt_cache = 10;
optional string custom_model_base_id = 11;
optional bool use_cross_region_inference = 12;
optional bool use_global_inference = 13;
}
message GcpProviderConfig {
optional string project_id = 1;
optional string region = 2;
}
message ProviderConfigResponse {
string provider_id = 1;
optional string base_url = 2;
optional string api_line = 3;
map<string, string> headers = 4;
optional string region = 5;
int64 api_key_length = 6;
bool has_access_token = 7;
bool has_refresh_token = 8;
optional string account_id = 9;
optional CommittedModelSelection plan_selection = 10;
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
}
message CommittedModelSelection {
string provider_id = 1;
string model_id = 2;
OpenRouterModelInfo model_info = 3;
}
message ProviderReasoningPatch {
optional bool enabled = 1;
optional string effort = 2; // "none" | "low" | "medium" | "high" | "xhigh"
optional int32 budget_tokens = 3;
}
message WriteProviderConfigPatch {
optional string api_key = 1;
optional string base_url = 2;
map<string, string> headers = 3;
optional string region = 4;
optional string api_line = 5;
optional string access_token = 6;
optional string refresh_token = 7;
optional string account_id = 8;
optional ProviderReasoningPatch reasoning = 9;
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
}
message WriteProviderConfigRequest {
string provider_id = 1;
WriteProviderConfigPatch patch = 2;
}
message CommitModelSelectionRequest {
string provider_id = 1;
string mode = 2;
string model_id = 3;
OpenRouterModelInfo model_info = 4;
}
message ClineRecommendedModel {
string id = 1;
string name = 2;
@@ -397,7 +251,7 @@ message ModelsApiOptions {
optional bool azure_identity = 44;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -438,7 +292,7 @@ message ModelsApiOptions {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -568,6 +422,53 @@ message OcaCompatibleModelInfo {
optional string error = 2;
}
// API Provider enumeration
enum ApiProvider {
ANTHROPIC = 0;
OPENROUTER = 1;
BEDROCK = 2;
VERTEX = 3;
OPENAI = 4;
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
ZAI = 31;
VERCEL_AI_GATEWAY = 32;
QWEN_CODE = 33;
DIFY = 34;
OCA = 35;
MINIMAX = 36;
HICAP = 37;
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
ANTHROPIC_CHAT = 0;
GEMINI_CHAT = 1;
@@ -707,7 +608,7 @@ message ModelsApiConfiguration {
optional string wandb_api_key = 87;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -753,7 +654,7 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -1,32 +0,0 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
enum RemoteConfigType {
RULE = 0;
WORKFLOW = 1;
SKILL = 2;
}
message RemoteConfigSetting {
RemoteConfigType type = 1;
string name = 2;
string content = 3;
bool enabled = 4;
bool locked = 5;
}
message RemoteConfigSettingsResponse {
repeated RemoteConfigSetting settings = 1;
}
service RemoteConfigService {
rpc getRemoteConfigSettings(Empty) returns (RemoteConfigSettingsResponse);
rpc toggleRemoteConfigSetting(StringRequest) returns (RemoteConfigSetting);
}
+1 -1
View File
@@ -23,7 +23,7 @@ message SlashCommandInfo {
string name = 1; // Command name without slash, e.g., "newtask", "smol"
string description = 2; // Human-readable description
string section = 3; // "default", "custom", or "cli"
bool cli_compatible = 4; // false for VS Code-only commands
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
}
// Response containing all available slash commands
+15 -15
View File
@@ -112,9 +112,6 @@ message Secrets {
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 144, 154, 176;
reserved "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -237,8 +234,8 @@ message Settings {
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional string plan_mode_api_provider = 124;
optional string act_mode_api_provider = 125;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
@@ -251,15 +248,19 @@ message Settings {
optional bool enable_checkpoints_setting = 135;
optional int32 shell_integration_timeout = 136;
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
optional bool cline_web_tools_enabled = 144;
optional string preferred_language = 145;
optional PlanActMode mode = 147;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional bool hooks_enabled = 152;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
optional bool opt_out_of_remote_config = 157;
optional bool open_telemetry_enabled = 158;
@@ -278,12 +279,14 @@ message Settings {
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
optional string plan_mode_cline_model_id = 178;
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
optional string act_mode_cline_model_id = 180;
optional OpenRouterModelInfo act_mode_cline_model_info = 181;
optional bool show_feature_tips = 182;
optional bool lazy_teammate_mode_enabled = 183;
}
message State {
@@ -387,11 +390,7 @@ message UpdateTaskSettingsRequest {
message UpdateSettingsRequest {
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
reserved 23; // was dictation_settings (dictation removed)
reserved 32, 34, 35, 41;
reserved 38; // was skills_enabled (removed - now always enabled)
reserved 43; // was lazy_teammate_mode_enabled (removed)
reserved 12; // was terminal_output_line_limit (removed; SDK command output limits are character-based)
reserved "native_tool_call_enabled", "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
Metadata metadata = 1;
optional ModelsApiConfiguration api_configuration = 2;
@@ -403,9 +402,10 @@ message UpdateSettingsRequest {
optional bool terminal_reuse_enabled = 9;
optional bool mcp_responses_collapsed = 10;
optional McpDisplayMode mcp_display_mode = 11;
optional int32 terminal_output_line_limit = 12;
optional PlanActMode mode = 13;
optional string preferred_language = 14;
reserved 16; // was strict_plan_mode_enabled (removed)
optional bool strict_plan_mode_enabled = 16;
optional FocusChainSettings focus_chain_settings = 17;
optional bool use_auto_condense = 18;
optional string custom_prompt = 19;
@@ -419,12 +419,17 @@ message UpdateSettingsRequest {
optional bool subagents_enabled = 29;
optional int32 subagent_terminal_output_line_limit = 30;
optional string cline_env = 31;
optional bool native_tool_call_enabled = 32;
optional OnboardingModelGroup onboarding_models = 33;
optional bool cline_web_tools_enabled = 34;
optional bool enable_parallel_tool_calling = 35;
optional bool background_edit_enabled = 36;
optional string oca_reasoning_effort = 37;
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
optional bool double_check_completion_enabled = 41;
optional bool show_feature_tips = 42;
optional bool lazy_teammate_mode_enabled = 43;
}
message UpdateTerminalConnectionTimeoutRequest {
@@ -451,11 +456,6 @@ message OnboardingProgressRequest {
optional string action = 2;
optional bool completed = 3;
optional string model_selected = 4;
optional string page = 5;
optional string page_variant = 6;
optional string user_type = 7;
optional int32 destination_step = 8;
optional string destination_page = 9;
}
message OnboardingModelGroup {
+12 -17
View File
@@ -12,8 +12,6 @@ option java_package = "bot.cline.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels a queued prompt by ID
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Clears the current task
@@ -34,14 +32,16 @@ service TaskService {
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
// Sends a response to a previous ask operation
rpc askResponse(AskResponseRequest) returns (Empty);
// Edits a previous user message, truncates following conversation, and regenerates
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
// Records task feedback (thumbs up/down)
rpc taskFeedback(StringRequest) returns (Empty);
// Shows task completion changes diff in a view
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
// Executes a quick win task with command and title
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
}
// Request message for creating a new task
@@ -82,14 +82,12 @@ message GetTaskHistoryRequest {
string search_query = 3;
string sort_by = 4;
bool current_workspace_only = 5;
int32 limit = 6;
int32 offset = 7;
}
// Response for task history
message TaskHistoryArray {
repeated TaskItem tasks = 1;
bool has_more = 2;
int32 total_count = 2;
}
// Task item details for history list
@@ -116,16 +114,6 @@ message AskResponseRequest {
repeated string files = 5;
}
// Request for editing a past user message and regenerating the conversation after it
message EditMessageAndRegenerateRequest {
Metadata metadata = 1;
int64 message_ts = 2;
string text = 3;
repeated string images = 4;
repeated string files = 5;
bool restore_workspace = 6;
}
// Request for executing a quick win task
message ExecuteQuickWinRequest {
Metadata metadata = 1;
@@ -137,3 +125,10 @@ message ExecuteQuickWinRequest {
message DeleteAllTaskHistoryCount {
int32 tasks_deleted = 1;
}
// Request for explaining changes with AI
message ExplainChangesRequest {
Metadata metadata = 1;
// Timestamp of the completion message to explain changes for
int64 message_ts = 2;
}
+1 -22
View File
@@ -67,6 +67,7 @@ enum ClineSay {
INFO = 26;
TASK_PROGRESS = 27;
ERROR_RETRY = 28;
GENERATE_EXPLANATION = 29;
HOOK_STATUS = 30;
HOOK_OUTPUT_STREAM = 31;
COMMAND_PERMISSION_DENIED = 32;
@@ -225,28 +226,12 @@ message ClineMessage {
ClineAskNewTask ask_new_task = 21;
ClineApiReqInfo api_req_info = 22;
ClineModelInfo model_info = 23;
// Convergent-replica fields (see webview-message-state-design.md):
// seq = monotonic freshness (higher seq wins for the same ts/identity)
// epoch = conversation/replica fence (older epoch is dropped by the webview)
int64 seq = 24;
int64 epoch = 25;
}
message ShowWebviewEvent {
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
}
message IntentEvent {
string action = 1;
string source = 2;
bool has_text = 3;
bool has_images = 4;
bool has_files = 5;
bool has_active_task = 6;
int32 text_length = 7;
}
// UiService provides methods for managing UI interactions
service UiService {
// Scrolls to a specific settings section in the settings view
@@ -264,9 +249,6 @@ service UiService {
// Subscribe to MCP button clicked events
rpc subscribeToMcpButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to customize button clicked events
rpc subscribeToMarketplaceButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to history button click events
rpc subscribeToHistoryButtonClicked(EmptyRequest) returns (stream Empty);
@@ -302,7 +284,4 @@ service UiService {
// Opens the Cline walkthrough
rpc openWalkthrough(EmptyRequest) returns (Empty);
// Tracks intent signals before task creation or first model activity
rpc trackIntent(IntentEvent) returns (Empty);
}
+9 -74
View File
@@ -14,66 +14,18 @@ import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
const require = createRequire(import.meta.url)
const isWindows = process.platform === "win32"
// Resolve the grpc-tools package root via its package.json (stable regardless of `main`), so we
// can both locate the bundled protoc and re-run its install script when the binary is missing.
const GRPC_TOOLS_DIR = path.dirname(require.resolve("grpc-tools/package.json"))
const GRPC_TOOLS_PROTOC = path.join(GRPC_TOOLS_DIR, "bin", isWindows ? "protoc.exe" : "protoc")
const GRPC_TOOLS_PROTOC = path.join(require.resolve("grpc-tools"), "../bin", isWindows ? "protoc.exe" : "protoc")
// Legacy compatibility: some older/local Windows setups provision protoc into tmp-protoc.
// Prefer that path when present, but fall back to the grpc-tools bundled binary used by CI/npm installs.
const LEGACY_WINDOWS_PROTOC = path.resolve("tmp-protoc/bin/protoc.exe")
const PROTOC = isWindows && fsSync.existsSync(LEGACY_WINDOWS_PROTOC) ? LEGACY_WINDOWS_PROTOC : GRPC_TOOLS_PROTOC
// `bun install` skips grpc-tools' `install` lifecycle script (`node-pre-gyp install`), so the prebuilt
// protoc is never downloaded into bin/. When it's missing, run that same command here to fetch it.
// grpc-tools depends on @mapbox/node-pre-gyp, which exposes the `node-pre-gyp` CLI.
function resolveNodePreGypCli() {
const candidates = ["@mapbox/node-pre-gyp/bin/node-pre-gyp", "node-pre-gyp/bin/node-pre-gyp"]
for (const candidate of candidates) {
try {
// Resolve from the grpc-tools package (its direct dependency).
return require.resolve(candidate, { paths: [GRPC_TOOLS_DIR] })
} catch {
// Fall back to resolving from this script's location (covers hoisted installs).
try {
return require.resolve(candidate)
} catch {
// try the next candidate
}
}
}
return null
}
function ensureProtocBinary() {
console.warn(chalk.yellow(`protoc not found at ${GRPC_TOOLS_PROTOC}; downloading the grpc-tools prebuilt binary...`))
const nodePreGypCli = resolveNodePreGypCli()
if (!nodePreGypCli) {
console.error(
chalk.red(
`Could not resolve the node-pre-gyp CLI from ${GRPC_TOOLS_DIR}. Run \`bun install\`, then retry \`bun run protos\`.`,
),
)
process.exit(1)
}
try {
// Mirrors grpc-tools' `scripts.install` ("node-pre-gyp install"): downloads the prebuilt
// protoc for the current platform/arch into grpc-tools/bin.
execFileSync(process.execPath, [nodePreGypCli, "install"], { cwd: GRPC_TOOLS_DIR, stdio: "inherit" })
} catch (error) {
console.error(chalk.red(`Failed to download protoc via node-pre-gyp: ${error?.message ?? error}`))
process.exit(1)
}
if (!fsSync.existsSync(GRPC_TOOLS_PROTOC)) {
console.error(chalk.red(`protoc still not found at ${GRPC_TOOLS_PROTOC} after node-pre-gyp install.`))
process.exit(1)
}
console.log(chalk.green("✓ protoc binary installed."))
}
if (!fsSync.existsSync(PROTOC)) {
// PROTOC only differs from GRPC_TOOLS_PROTOC when the legacy Windows path exists, so a missing
// PROTOC always means the grpc-tools-bundled protoc needs to be fetched.
ensureProtocBinary()
const windowsHint = isWindows
? ` Neither ${LEGACY_WINDOWS_PROTOC} nor the grpc-tools bundled protoc at ${GRPC_TOOLS_PROTOC} exists.`
: ""
console.error(chalk.red(`protoc not found at ${PROTOC}.${windowsHint}`))
process.exit(1)
}
const PROTO_DIR = path.resolve("proto")
@@ -82,26 +34,9 @@ const GRPC_JS_OUT_DIR = path.resolve("src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.resolve("src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.resolve("dist-standalone/proto")
// protoc invokes the ts-proto plugin as a child process, so it needs a path it can
// directly execute. On POSIX the package's JS bin (with its shebang) works. On
// Windows protoc cannot exec a bare .js or bun's `.bunx` shim ("%1 is not a valid
// Win32 application"), and the package manager's `.cmd` shim location/name varies
// (npm vs bun's hoisted store). To be package-manager-agnostic, generate a tiny
// .cmd wrapper that runs the resolved plugin JS via `node`.
function resolveTsProtoPlugin() {
const pluginJs = require.resolve("ts-proto/protoc-gen-ts_proto")
if (!isWindows) {
return pluginJs
}
const wrapperDir = path.resolve("dist-standalone")
fsSync.mkdirSync(wrapperDir, { recursive: true })
const wrapperPath = path.join(wrapperDir, "protoc-gen-ts_proto.cmd")
// %* forwards protoc's plugin args/stdio to the JS entry run under node.
fsSync.writeFileSync(wrapperPath, `@echo off\r\nnode "${pluginJs}" %*\r\n`)
return wrapperPath
}
const TS_PROTO_PLUGIN = resolveTsProtoPlugin()
const TS_PROTO_PLUGIN = isWindows
? path.resolve("node_modules/.bin/protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
const TS_PROTO_OPTIONS = [
"env=both",
+1 -42
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env node
const { execSync } = require("child_process")
const fs = require("fs")
const path = require("path")
const esbuild = require("esbuild")
const watch = process.argv.includes("--watch")
@@ -55,46 +53,7 @@ async function main() {
}
}
// tsc does not delete output for source/tests that were removed or are no longer
// part of tsconfig.test.json. The VS Code test runner globs out/src/**/*.test.js,
// so stale compiled tests can still run unless we clear the test build output first.
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
// Single source of truth for the test-runner split: any *.test.ts that imports
// from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts),
// and any that imports from "vitest" is owned by the vitest runner (test:vitest,
// see vitest.config.ts). Neither may be compiled into the Node-based
// @vscode/test-cli `out/` tree: Node cannot load `bun:test`, and vitest suites
// use vitest-only APIs/matchers (e.g. `toHaveBeenCalledWith`) that the mocha
// runner does not provide. Generate a tsconfig that excludes them so the
// integration compile only ever sees mocha-owned tests.
const projectRoot = path.join(__dirname, "..")
const nonMochaTestImport =
/from\s+["'](?:bun:test|vitest(?:\/[^"']*)?|@vitest\/[^"']*)["']/
function collectNonMochaTestFiles(dir, acc) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
collectNonMochaTestFiles(full, acc)
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
if (nonMochaTestImport.test(fs.readFileSync(full, "utf-8"))) {
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
}
}
}
return acc
}
const nonMochaOwnedTests = collectNonMochaTestFiles(path.join(projectRoot, "src"), [])
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
const JSON5 = require("json5")
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...nonMochaOwnedTests]
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
execSync(`tsc -p ${JSON.stringify(generatedConfigPath)} --outDir out`, { encoding: "utf-8" })
execSync("tsc -p ./tsconfig.test.json --outDir out", { encoding: "utf-8" })
main().catch((e) => {
console.error(e)
-129
View File
@@ -1,129 +0,0 @@
// Dead-source finder: uses esbuild's own bundle reachability (the same analysis
// that drives tree-shaking + minification mangling) to compute which src/ files
// are reachable from BOTH shipped entry points:
// - src/extension.ts (VS Code extension host)
// - src/standalone/cline-core.ts (standalone host used by JetBrains + CLI)
//
// A src/*.ts file that is NOT in the union of metafile inputs for those two
// builds is unreachable from any shipped entry => dead (modulo dynamic import()
// of computed specifiers, which esbuild surfaces separately).
//
// Run: node scripts/find-dead-src.mjs
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
import { glob } from "glob"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, "..")
const aliases = {
"@": path.join(root, "src"),
"@core": path.join(root, "src/core"),
"@integrations": path.join(root, "src/integrations"),
"@services": path.join(root, "src/services"),
"@shared": path.join(root, "src/shared"),
"@utils": path.join(root, "src/utils"),
"@packages": path.join(root, "src/packages"),
}
const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
for (const [alias, aliasPath] of Object.entries(aliases)) {
const aliasRegex = new RegExp(`^${alias}($|/.*)`)
build.onResolve({ filter: aliasRegex }, (args) => {
const importPath = args.path.replace(alias, aliasPath)
const exts = [".ts", ".tsx", ".js", ".jsx"]
if (fs.existsSync(importPath)) {
const stats = fs.statSync(importPath)
if (stats.isDirectory()) {
for (const ext of exts) {
const idx = path.join(importPath, `index${ext}`)
if (fs.existsSync(idx)) return { path: idx }
}
} else {
return { path: importPath }
}
}
for (const ext of exts) {
if (fs.existsSync(`${importPath}${ext}`)) return { path: `${importPath}${ext}` }
}
return undefined
})
}
},
}
const common = {
bundle: true,
minify: false,
sourcemap: false,
logLevel: "silent",
format: "cjs",
platform: "node",
metafile: true,
write: false,
absWorkingDir: root,
tsconfig: path.join(root, "tsconfig.json"),
packages: "external",
plugins: [aliasResolverPlugin],
define: { "process.env.IS_DEV": "false", "process.env.IS_TEST": "false" },
banner: { js: "const _importMetaUrl=require('url').pathToFileURL(__filename)" },
}
async function inputsFor(entry, external) {
const r = await esbuild.build({ ...common, entryPoints: [entry], external })
return new Set(Object.keys(r.metafile.inputs).filter((f) => f.startsWith("src/") && /\.tsx?$/.test(f)))
}
const ext = await inputsFor("src/extension.ts", ["vscode"])
const standalone = await inputsFor("src/standalone/cline-core.ts", [
"vscode",
"@grpc/reflection",
"grpc-health-check",
"better-sqlite3",
])
const live = new Set([...ext, ...standalone])
// Third consumer: the webview (webview-ui/) is a separate Vite/React build that
// imports extension code ONLY from src/shared (via "@shared/*" alias or relative
// "../src/shared/*" paths). Any src/shared file referenced from webview-ui/src is
// therefore live even if the extension-host/standalone bundles don't reach it.
// Conservatively mark every src/shared file mentioned by the webview as live.
const webviewFiles = await glob("webview-ui/src/**/*.{ts,tsx}", { cwd: root })
const sharedMentionedByWebview = new Set()
for (const wf of webviewFiles) {
const text = fs.readFileSync(path.join(root, wf), "utf8")
// Match @shared/X or .../src/shared/X import specifiers and map to src/shared/X
const re = /(?:@shared\/|src\/shared\/)([A-Za-z0-9_./-]+)/g
let m
while ((m = re.exec(text))) {
const rel = m[1].replace(/\.(ts|tsx|js|jsx)$/, "")
for (const cand of [`src/shared/${rel}.ts`, `src/shared/${rel}.tsx`, `src/shared/${rel}/index.ts`]) {
if (fs.existsSync(path.join(root, cand))) sharedMentionedByWebview.add(cand)
}
}
}
for (const f of sharedMentionedByWebview) live.add(f)
console.log(`src/shared files referenced by webview: ${sharedMentionedByWebview.size}`)
// All non-test, non-.d.ts source files on disk.
const allSrc = (await glob("src/**/*.{ts,tsx}", { cwd: root }))
.filter((f) => !/\.test\.tsx?$/.test(f))
.filter((f) => !f.endsWith(".d.ts"))
.filter((f) => !f.includes("/__tests__/"))
.filter((f) => !f.startsWith("src/test/"))
.filter((f) => !f.startsWith("src/generated/")) // generated host glue
.filter((f) => !f.startsWith("src/dev/")) // dev-only tooling
const dead = allSrc.filter((f) => !live.has(f)).sort()
console.log(`extension inputs: ${ext.size}`)
console.log(`standalone inputs: ${standalone.size}`)
console.log(`union live src files: ${live.size}`)
console.log(`candidate dead files: ${dead.length}`)
fs.writeFileSync("/tmp/dead-src.json", JSON.stringify(dead, null, "\t"))
console.log("--- dead candidates written to /tmp/dead-src.json ---")
@@ -0,0 +1,212 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(__dirname, "../../..");
const sdkCatalogPath = path.join(
repoRoot,
"sdk/packages/llms/src/catalog/catalog.generated.ts",
);
const outputPath = path.join(
__dirname,
"../src/shared/models/models-dev-catalog.generated.ts",
);
const { GENERATED_PROVIDER_MODELS } = await import(
pathToFileURL(sdkCatalogPath).href
);
const providerLabels = Object.fromEntries([
["anthropic", "Anthropic"],
["bedrock", "Amazon Bedrock"],
["vertex", "GCP Vertex AI"],
["gemini", "Google Gemini"],
["openai-native", "OpenAI"],
["openai-codex", "ChatGPT Subscription"],
["deepseek", "DeepSeek"],
["xai", "xAI"],
["together", "Together"],
["sapaicore", "SAP AI Core"],
["fireworks", "Fireworks AI"],
["groq", "Groq"],
["cerebras", "Cerebras"],
["sambanova", "SambaNova"],
["nebius", "Nebius AI Studio"],
["huggingface", "Hugging Face"],
["openrouter", "OpenRouter"],
["vercel-ai-gateway", "Vercel AI Gateway"],
["aihubmix", "AIhubmix"],
["baseten", "Baseten"],
["zai", "Z AI"],
["lmstudio", "LM Studio"],
["requesty", "Requesty"],
["moonshot", "Moonshot"],
["minimax", "MiniMax"],
["wandb", "W&B Inference by CoreWeave"],
["mistral", "Mistral"],
["doubao", "Bytedance Doubao"],
["qwen", "Alibaba Qwen"],
["huawei-cloud-maas", "Huawei Cloud MaaS"],
["hicap", "Hicap"],
["nousResearch", "NousResearch"],
["openai", "OpenAI Compatible"],
["ollama", "Ollama"],
["litellm", "LiteLLM"],
["claude-code", "Claude Code"],
["qwen-code", "Qwen Code"],
["dify", "Dify.ai"],
["oca", "Oracle Code Assist"],
["vscode-lm", "GitHub Copilot"],
["cline", "Cline"],
["cline-pass", "ClinePass"],
["asksage", "AskSage"],
]);
const providerOrder = [
"cline",
"cline-pass",
"openai-codex",
"gemini",
"openai",
"anthropic",
"bedrock",
"vscode-lm",
"deepseek",
"openai-native",
"openrouter",
"ollama",
"vertex",
"litellm",
"claude-code",
"sapaicore",
"mistral",
"zai",
"groq",
"cerebras",
"vercel-ai-gateway",
"baseten",
"requesty",
"fireworks",
"together",
"qwen",
"qwen-code",
"doubao",
"lmstudio",
"moonshot",
"huggingface",
"nebius",
"asksage",
"xai",
"sambanova",
"huawei-cloud-maas",
"dify",
"oca",
"minimax",
"hicap",
"aihubmix",
"nousResearch",
"wandb",
];
function toLegacyModelInfo(model) {
const capabilities = new Set(model.capabilities ?? []);
const output = {
name: model.name,
maxTokens: model.maxTokens,
contextWindow: model.contextWindow ?? model.maxInputTokens,
supportsImages: capabilities.has("images"),
supportsPromptCache: capabilities.has("prompt-cache"),
supportsReasoning: capabilities.has("reasoning"),
inputPrice: model.pricing?.input ?? 0,
outputPrice: model.pricing?.output ?? 0,
cacheWritesPrice: model.pricing?.cacheWrite ?? 0,
cacheReadsPrice: model.pricing?.cacheRead ?? 0,
supportsTools: capabilities.has("tools"),
};
for (const key of Object.keys(output)) {
if (output[key] === undefined) {
delete output[key];
}
}
return output;
}
const providerModels = Object.fromEntries(
Object.entries(GENERATED_PROVIDER_MODELS.providers).map(
([providerId, models]) => [
providerId,
Object.fromEntries(
Object.entries(models).map(([modelId, model]) => [
modelId,
toLegacyModelInfo(model),
]),
),
],
),
);
const providerOptions = providerOrder
.filter((value) => providerLabels[value])
.map((value) => ({ value, label: providerLabels[value] }));
const file = `/**
* Auto-generated from @cline/llms models.dev catalog.
*
* Source: sdk/packages/llms/src/catalog/catalog.generated.ts
* Do not edit by hand; run apps/vscode/scripts/generate-models-dev-catalog.mjs after updating the SDK model catalog.
*/
import type { ApiProvider, ModelInfo, OpenAiCompatibleModelInfo } from "../api"
export const modelsDevProviderModels = ${JSON.stringify(providerModels, null, "\t")} as const satisfies Record<string, Record<string, ModelInfo | OpenAiCompatibleModelInfo>>
export const modelsDevProviderOptions = ${JSON.stringify(providerOptions, null, "\t")} as const satisfies ReadonlyArray<{ value: ApiProvider; label: string }>
export function getModelsDevProviderModels(provider: ApiProvider | string): Record<string, ModelInfo> {
\treturn (modelsDevProviderModels[provider as keyof typeof modelsDevProviderModels] ?? {}) as Record<string, ModelInfo>
}
export const modelsDevAnthropicModels = getModelsDevProviderModels("anthropic")
export const modelsDevBedrockModels = getModelsDevProviderModels("bedrock")
export const modelsDevCerebrasModels = getModelsDevProviderModels("cerebras")
export const modelsDevDeepSeekModels = getModelsDevProviderModels("deepseek")
export const modelsDevDoubaoModels = getModelsDevProviderModels("doubao")
export const modelsDevFireworksModels = getModelsDevProviderModels("fireworks")
export const modelsDevGeminiModels = getModelsDevProviderModels("gemini")
export const modelsDevGroqModels = getModelsDevProviderModels("groq")
export const modelsDevHuggingFaceModels = getModelsDevProviderModels("huggingface")
export const modelsDevMinimaxModels = getModelsDevProviderModels("minimax")
export const modelsDevMistralModels = getModelsDevProviderModels("mistral")
export const modelsDevMoonshotModels = getModelsDevProviderModels("moonshot")
export const modelsDevNebiusModels = getModelsDevProviderModels("nebius")
export const modelsDevNousResearchModels = getModelsDevProviderModels("nousResearch")
export const modelsDevOpenAiCodexModels = getModelsDevProviderModels("openai-codex")
export const modelsDevOpenAiNativeModels = getModelsDevProviderModels("openai-native")
export const modelsDevSambanovaModels = getModelsDevProviderModels("sambanova")
export const modelsDevSapAiCoreModels = getModelsDevProviderModels("sapaicore")
export const modelsDevVertexModels = getModelsDevProviderModels("vertex")
export const modelsDevWandbModels = getModelsDevProviderModels("wandb")
export const modelsDevXaiModels = getModelsDevProviderModels("xai")
`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, file);
await execFileAsync(
process.execPath,
[
require.resolve("@biomejs/biome/bin/biome"),
"format",
"--write",
outputPath,
],
{ cwd: path.resolve(__dirname, "..") },
);
+1 -1
View File
@@ -453,7 +453,7 @@ async function main() {
await fs.writeFile(STATE_PROTO_PATH, protoContent)
console.log(`Updated ${STATE_PROTO_PATH}`)
console.log("\nGeneration complete! Run 'bun run protos' to regenerate TypeScript from protos.")
console.log("\nGeneration complete! Run 'npm run protos' to regenerate TypeScript from protos.")
}
main().catch((error) => {

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