Compare commits

..
Author SHA1 Message Date
Saoud Rizwan c2c3e48bdc fix(cli): version hub compatibility by cli release 2026-06-03 23:54:10 -07:00
1867 changed files with 179884 additions and 133476 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.
+103 -102
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,15 +48,104 @@ 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.
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
@@ -109,26 +153,28 @@ 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.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
Exception: State needed immediately at extension startup (before cache is ready)
Example pattern:
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading after initialization
const value = controller.stateManager.getGlobalStateKey("myKey")
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
@@ -157,48 +203,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)`
+3 -15
View File
@@ -7,10 +7,10 @@ body:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: cline-surface
id: plugin-type
attributes:
label: Cline Surface
description: Which Cline surface are you reporting a bug for?
label: Plugin Type
description: Which plugin are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
@@ -59,18 +59,6 @@ body:
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
id: system-info
attributes:
+7 -7
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,13 +38,13 @@ 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.
## Global State Keys (silent failure risk)
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
## Slash Commands (3 places)
- `src/core/slash-commands/index.ts` — definitions.
+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 }}"
@@ -1,9 +1,6 @@
name: ext-vscode-publish-nightly
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
@@ -34,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
@@ -56,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
@@ -114,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
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -29,7 +29,7 @@ jobs:
}
// Check if VSCode Extension is selected
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
if (!labels.includes('VS Code')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -41,7 +41,7 @@ jobs:
}
// Check if CLI is selected
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
if (!labels.includes('CLI')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
-11
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,11 +81,3 @@ apps/vscode/webview-ui/src/**/*.js.map
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
+8
View File
@@ -39,6 +39,14 @@
"sdk/packages/core/src/auth/**"
],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": [
"sdk/packages/core/src/services/telemetry/core-events.ts"
],
"severity": "medium"
}
]
}
+5 -1
View File
@@ -16,9 +16,13 @@
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
"path": "DOC.md",
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "sdk/ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
},
{
"path": "sdk/AGENTS.md",
+17 -20
View File
@@ -36,11 +36,8 @@ event names. It exports:
1. Add the constant to `CORE_TELEMETRY_EVENTS`
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
3. Update the Event Catalog section in `DOC.md`
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
## The Activation Funnel
@@ -85,7 +82,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
config dir.
The canonical pattern is in `apps/cli/src/main.ts`:
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
```ts
if (configDir) setClineDir(configDir);
@@ -93,18 +90,18 @@ setHomeDir(homedir());
captureCliExtensionActivated(); // <-- after dir overrides
```
## Hub Daemon Telemetry
## Hub Daemon Metadata Forwarding
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
metadata into the daemon argv so the daemon can reconstruct an equivalent
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
```
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
```
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
## Auth Lifecycle Completeness
@@ -123,10 +120,10 @@ canonical examples of all four phases.
## Single Telemetry Service Per Host
On VS Code, all callers go through the lazy `telemetryService` proxy in
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
use. Do not let individual controllers construct their own `ITelemetryService` — that
fragments distinct-id state, opt-out tracking, and flush ownership.
On VS Code, the telemetry handle is built **once** in `activate()`
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
command, and daemon spawn payload. Do not let individual controllers construct their own
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
+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
+19 -39
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,10 +65,10 @@
},
{
"type": "shell",
"command": "bun run build:webview",
"command": "npm run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -86,10 +86,10 @@
},
{
"type": "shell",
"command": "bun run build:webview:test",
"command": "npm run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -108,22 +108,22 @@
},
{
"type": "shell",
"command": "bun run dev:webview",
"command": "npm run dev:webview",
"group": "build",
"problemMatcher": [
{
"pattern": [
{
"regexp": "^(?!)((?:.*))$",
"kind": "file",
"regexp": ".",
"file": 1,
"message": 1
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^Building webview for|^\\s*VITE",
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
"beginsPattern": ".",
"endsPattern": "."
}
}
],
@@ -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": [
-92
View File
@@ -1,97 +1,5 @@
# Changelog
## [4.0.0]
### Added
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
### Changed
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
### Fixed
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
- Add Claude Fable 5 model support.
### Fixed
- Fix MiniMax M3 thinking controls across gateways.
### Changed
- Clean up the Codex model list.
## [3.88.1]
### Added
- Add a debug section in settings for Cline testers.
### Fixed
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
## [3.88.0]
### Added
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
### Fixed
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
### Changed
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
## [3.87.0]
### Added
+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:**
+4 -4
View File
@@ -149,7 +149,7 @@ Toggle between Plan mode and Act mode. In Plan mode, Cline explores your codebas
## Rules and Skills
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
## Works With Every Model
@@ -158,10 +158,10 @@ Cline is not locked to a single AI provider. Use whichever model fits your workf
| Provider | Models |
|----------|--------|
| Anthropic | Claude Opus, Sonnet, Haiku |
| OpenAI | GPT series models |
| Google | Gemini series models |
| OpenAI | GPT series model |
| Google | Gemini series model |
| OpenRouter | 200+ models from any provider |
| Vercel AI Gateway | Route to many providers through one gateway |
| Vercel AI Gateway | Models through Vercel AI Gateway |
| AWS Bedrock | Claude, Llama, and more |
| Azure / GCP Vertex | All hosted models |
| Cerebras / Groq | Fast inference models |
+1 -3
View File
@@ -1,9 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": [
"../sdk/biome.json"
],
"extends": ["../sdk/biome.json"],
"linter": {
"rules": {
"a11y": {
@@ -9,13 +9,12 @@ Use this skill when the user asks to release the CLI, publish `cline`, bump the
The CLI is npm-only. Do not add alternate distribution or signing steps.
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
> Working directory: this skill lives in the SDK sub-monorepo. Run `cd sdk` (from the repo root) before any of the shell commands below. Paths in commands and instructions (e.g. `apps/cli/package.json`, `bun release cli`) are written relative to `sdk/`.
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
## Release contract
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
- Version source: `apps/cli/package.json`.
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
@@ -31,93 +30,8 @@ The skill should guide the user through one release preparation flow, then offer
- Always ask before pushing commits or tags.
- Do not amend commits unless explicitly requested.
## Step 0: Release the SDK first if it changed
Do this before anything else in the Workflow below.
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
1. Check for unreleased SDK changes.
```sh
git fetch origin --tags
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
```
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
2. Decide the SDK version bump.
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
3. Draft the SDK release notes and update the changelog.
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
4. Bump versions and regenerate.
```sh
bun run version <version>
```
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
5. Commit and push the bump to `main`.
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
```sh
git add -A
git commit -m "chore(sdk): release v<version>"
```
Ask before pushing:
```sh
git push origin HEAD
```
6. Trigger the SDK publish workflow on the `latest` channel.
```sh
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
```
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
7. Wait for the SDK workflow to succeed before starting the CLI release.
```sh
gh run watch <run-id> --exit-status
```
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
```sh
git checkout main && git pull --ff-only
```
Then continue with the Workflow below.
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
## Workflow
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
1. Gather context.
```sh
@@ -132,10 +46,10 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
2. Collect release commits.
```sh
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
```
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
3. Draft user-facing release notes.
-181
View File
@@ -1,186 +1,5 @@
# Cline CLI Changelog
## 3.0.39
- You can now select Cline free models on the ClinePass provider in the model picker
- Removed the retired ClinePass GLM 5.1 model
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
- `str_replace` edits now report accurate diffs
- Fixed context compaction so canonical session history is preserved
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
- Cline provider requests now send versioned client-identity headers
## 3.0.38
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
- Polished the status bar usage display and ClinePass model name
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
- The thinking-level picker now defaults its cursor to Medium instead of Off
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
## 3.0.37
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
- Fixed plan/act mode notices being dropped from prompts sent to the model
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
## 3.0.36
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
## 3.0.35
- ClinePass is now enabled for all CLI users
- Recover missing interactive sessions when reading messages
- Format structured commands in history export
- Add the subscription promo code when linking to the dashboard subscription page
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
- Advertise run commands as shell strings (from SDK v0.0.55)
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
## 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
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
- Auto-approve toggles now apply immediately when changed
- Feature flags now resolve using your user ID on startup
- Fixed Cline model display names so they resolve by model name
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
## 3.0.27
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
- Added a prefilled MCP install wizard command for quicker MCP server setup
- Improved error handling and messaging when plugin MCP OAuth authorization fails
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
## 3.0.26
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
## 3.0.25
- Added ClinePass support, with selectable ClinePass models in the model picker
- Made model picker sections expandable
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
- Encouraged parallel tool calls for faster task execution
- Capped tool output for bash commands and file reads to keep large output within context limits
- Allowed ranged reads on large files
- Fixed apply_patch to fail when a hunk is skipped
- Fixed run_commands to return captured stdout on failure and handle split heredocs
- Fixed search tools to treat zero results as success
- Fixed disabled-reasoning handling for StepFun flash
- Fixed history resume rendering isolation
- Fixed the Hugging Face URL
- Fixed Cline OAuth token formatting in provider config
## 3.0.24
- Plugin commands can now submit prompts to the agent
- Added support for overriding the API base URL
- Open the verification URL automatically when starting device authentication
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
- Suppressed flickering console windows on Windows
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 3.0.23
- Fixed Vertex AI GCP settings configuration
- Fixed the Azure Foundry API version
- Added support for configured agents as subagent tools
- Centralized OAuth management into the SDK
- Fixed an error caused by disabled reasoning on Fable 5
## 3.0.22
- Added support for the Claude Fable 5 model
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
## 3.0.21
- Added a global auto-update setting that controls automatic updates on CLI startup
- Added a Cline credits refill link
- Fixed scrolling for inline ask-question responses
- Fixed connector thread session routing and stale hub session handling
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
- Fixed empty message content replay for Bedrock
- Cleaned up the OpenAI Codex model list
## 3.0.20
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
## 3.0.19
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
## 3.0.18
- Fix Slack channel mentions so replies post in the original message's thread.
- Fix the abort indicator to clear immediately when a task is cancelled.
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
## 3.0.17
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
+1 -1
View File
@@ -416,7 +416,7 @@ Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
## Publishing
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`.cline/skills/publish-cli/SKILL.md` at the repo root).
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`apps/cli/.cline/skills/publish-cli/SKILL.md`).
From the `apps/cli` workspace:
-24
View File
@@ -163,30 +163,6 @@ cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
```
### MCP servers
Manage MCP servers with the interactive wizard:
```sh
cline mcp
cline config mcp
```
Open the add-server wizard with the name, transport, and command or URL already filled in with `cline mcp install` (`cline mcp add` also works). Stdio servers use everything after `--` as the command and arguments:
```sh
cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
```
Remote HTTP and SSE servers take a name, transport, and URL. The wizard still asks for auth details before saving:
```sh
cline mcp install ctx7 --transport http https://mcp.context7.com/mcp
cline mcp install events --transport sse https://example.com/sse
```
Because this command opens the wizard, it requires a TTY.
### Connectors
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
+1 -15
View File
@@ -85,20 +85,6 @@ const result = await Bun.build({
],
define: {
"process.env.NODE_ENV": '"production"',
...(process.env.TELEMETRY_SERVICE_API_KEY
? {
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
"TELEMETRY_SERVICE_API_KEY",
),
}
: {}),
...(process.env.ERROR_SERVICE_API_KEY
? {
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
"ERROR_SERVICE_API_KEY",
),
}
: {}),
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
"OTEL_TELEMETRY_ENABLED",
),
@@ -121,7 +107,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 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.39",
"version": "3.0.17",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -87,7 +87,6 @@
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
+1 -19
View File
@@ -511,7 +511,6 @@ export class AcpAgent implements Agent {
private async buildConfig(session: SessionState): Promise<Config> {
const cwd = session.cwd || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
// Resolve credentials: env vars take precedence, then session provider.
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
@@ -520,7 +519,6 @@ export class AcpAgent implements Agent {
providerId,
mode: session.currentMode,
});
const cliBuildInfo = getCliBuildInfo();
return {
providerId,
@@ -539,23 +537,7 @@ export class AcpAgent implements Agent {
enableAgentTeams: false,
enableTools: true,
cwd,
workspaceRoot,
extensionContext: {
client: {
name: "cline-acp",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
workspaceName: cwd,
ide: "Terminal Shell",
platform: process.platform,
},
},
workspaceRoot: resolveWorkspaceRoot(cwd),
};
}
}
+59 -24
View File
@@ -1,6 +1,11 @@
import type { ProviderSettingsManager } from "@cline/core";
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
import { getPersistedProviderApiKey } from "../commands/auth";
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { OAuthCredentials } from "../commands/auth";
import {
getPersistedProviderApiKey,
saveOAuthProviderSettings,
toProviderApiKey,
} from "../commands/auth";
import { writeDiagnostic } from "../utils/output";
/**
@@ -25,13 +30,37 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
* If the OAuth flow requires interactive prompts (rare), defaults are used
* when available; otherwise an error is thrown.
*/
async function performOAuthLogin(input: {
providerId: AcpAuthMethodId;
providerSettingsManager: ProviderSettingsManager;
}): Promise<string> {
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
[import("@cline/core"), import("open")],
);
async function performOAuthLogin(
providerId: AcpAuthMethodId,
existingSettings: ProviderSettings | undefined,
): Promise<OAuthCredentials> {
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
await Promise.all([
import("@cline/core"),
import("open"),
import("@cline/core").then((m) => ({
loginClineOAuth: m.loginClineOAuth as (input: {
useWorkOSDeviceAuth?: boolean;
apiBaseUrl: string;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>,
loginOpenAICodex: m.loginOpenAICodex as (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>,
})),
]);
const callbacks = createOAuthClientCallbacks({
onPrompt: ({ defaultValue }) => {
@@ -53,18 +82,18 @@ async function performOAuthLogin(input: {
},
});
const settings = await loginAndSaveProviderOAuthCredentials(
input.providerSettingsManager,
input.providerId,
{ callbacks },
);
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
if (!apiKey) {
throw new Error(
`OAuth login did not persist credentials for ${input.providerId}`,
);
if (providerId === "cline") {
return coreOAuth.loginClineOAuth({
apiBaseUrl:
existingSettings?.baseUrl?.trim() ||
getClineEnvironmentConfig().apiBaseUrl,
callbacks,
useWorkOSDeviceAuth: true,
});
}
return apiKey;
// openai-codex
return coreOAuth.loginOpenAICodex(callbacks);
}
export interface AcpAuthResult {
@@ -93,10 +122,16 @@ export async function authenticateAcpProvider(
// Perform a fresh OAuth login.
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}`);
const apiKey = await performOAuthLogin({
providerId: methodId,
const credentials = await performOAuthLogin(methodId, existing);
saveOAuthProviderSettings(
providerSettingsManager,
});
methodId,
existing,
credentials,
);
const apiKey = toProviderApiKey(methodId, credentials);
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
return { providerId: methodId, apiKey };
}
-21
View File
@@ -746,27 +746,6 @@ Break work into clear steps.`,
).toBe(true);
});
it("routes mcp install and requires a TTY for the prefilled wizard", () => {
const result = runCli(
[
"mcp",
"install",
"fs",
"--",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp",
],
{ env: createIsolatedEnv() },
);
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+22 -94
View File
@@ -18,8 +18,6 @@ interface KeyStep {
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
@@ -53,40 +51,16 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function createCliEnv(): NodeJS.ProcessEnv {
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
};
}
function runInteractiveCli(
steps: KeyStep[],
options?: {
launchConfigView?: boolean;
launchArgs?: string[];
env?: NodeJS.ProcessEnv;
},
): CliResult {
const env = options?.env ?? createCliEnv();
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
@@ -106,13 +80,9 @@ function runInteractiveCli(
"-k",
"test-key",
];
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
@@ -120,7 +90,21 @@ function runInteractiveCli(
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env,
env: {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
},
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
@@ -204,62 +188,6 @@ describe("cli interactive e2e", () => {
expect(output).toContain("/ for commands · @ for files");
});
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
timeout: 120_000,
}, () => {
const env = createCliEnv();
// Seed one session; the invalid key makes the run fail fast while
// still persisting a resumable session record.
const seed = spawnSync(
bunExec,
[
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
expect(seed.error).toBeUndefined();
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 60_000,
});
expect(history.error).toBeUndefined();
expect(history.status).toBe(0);
const historyRows = JSON.parse(history.stdout) as unknown[];
expect(historyRows.length).toBeGreaterThan(0);
// history picker -> Enter resumes the seeded session in the
// interactive TUI -> double Ctrl+C exits it. Regression guard for
// the Bun "panic(main thread): Segmentation fault" that occurred
// when the resumed TUI shared the picker's process (a second
// OpenTUI renderer in one process crashes natively on teardown).
const result = runInteractiveCli(
[
// Select the seeded session in the picker.
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
// Give the resumed TUI time to start, then double-press
// Ctrl+C; the harness appends the final press 0.2s later.
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
],
{ launchArgs: ["history"], env },
);
const output = outputOf(result);
// The exit summary only prints after the resumed interactive TUI ran
// and shut down cleanly; the history picker alone never prints it.
expect(output).toContain("Session Summary");
expect(output).not.toContain("panic(");
expect(output).not.toContain("Segmentation fault");
expect(result.status).toBe(0);
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
+1 -37
View File
@@ -2,37 +2,7 @@ import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
getPersistedProviderApiKey,
normalizeAuthProviderId,
parseAuthCommandArgs,
saveOAuthProviderSettings,
} from "./auth";
describe("parseAuthCommandArgs", () => {
it("parses Azure API version quick setup option", () => {
expect(
parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--apikey",
"key",
"--modelid",
"gpt-4.1",
"--baseurl",
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
"--azure-api-version",
"2025-01-01-preview",
]),
).toMatchObject({
explicitProvider: "openai-compatible",
apikey: "key",
modelid: "gpt-4.1",
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
azureApiVersion: "2025-01-01-preview",
});
});
});
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
describe("saveOAuthProviderSettings", () => {
it("preserves existing manual apiKey while updating OAuth tokens", () => {
@@ -97,12 +67,6 @@ describe("getPersistedProviderApiKey", () => {
});
});
describe("normalizeAuthProviderId", () => {
it("keeps CLI-only codex shorthand in CLI parsing", () => {
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
});
});
describe("loadAuthTuiRuntime", () => {
it("loads OpenTUI React after provider catalog initialization", async () => {
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
+125 -46
View File
@@ -3,12 +3,11 @@ import {
BUILT_IN_PROVIDER,
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
loginAndSaveProviderOAuthCredentials,
listLocalProviders,
type ProviderSettings,
type ProviderSettingsManager,
saveProviderOAuthCredentials,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import React from "react";
@@ -21,8 +20,6 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -40,6 +37,40 @@ const c = {
green: "\x1b[32m",
};
type CoreOAuthApi = {
loginClineOAuth: (input: {
apiBaseUrl: string;
useWorkOSDeviceAuth?: boolean;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOcaOAuth: (input: {
mode?: "internal" | "external";
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOpenAICodex: (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>;
};
type AuthIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
@@ -50,7 +81,6 @@ type AuthQuickSetupInput = {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
};
type AuthCommandInput = {
@@ -60,7 +90,6 @@ type AuthCommandInput = {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
};
type ParsedAuthCommandArgs = {
@@ -68,10 +97,30 @@ type ParsedAuthCommandArgs = {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
parseError?: string;
};
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
if (!cachedCoreOAuthApi) {
cachedCoreOAuthApi = import("@cline/core").then((module) => {
const runtimeApi = module as Partial<CoreOAuthApi>;
if (
typeof runtimeApi.loginClineOAuth !== "function" ||
typeof runtimeApi.loginOcaOAuth !== "function" ||
typeof runtimeApi.loginOpenAICodex !== "function"
) {
throw new Error(
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
);
}
return runtimeApi as CoreOAuthApi;
});
}
return cachedCoreOAuthApi;
}
/**
* Create the `auth` subcommand for Commander.
*
@@ -88,8 +137,7 @@ export function createAuthCommand(): Command {
.option("-p, --provider <id>", "provider id")
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "model id")
.option("-b, --baseurl <url>", "base URL")
.option("--azure-api-version <version>", "Azure API version");
.option("-b, --baseurl <url>", "base URL");
return cmd;
}
@@ -106,7 +154,6 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
}>();
const positionalProvider = cmd.args[0];
return {
@@ -114,7 +161,6 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
};
}
@@ -154,12 +200,6 @@ async function ensureQuickSetupInputValid(
) {
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
}
if (
input.azureApiVersion?.trim() &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
) {
return "Azure API version is only supported for OpenAI-compatible providers";
}
return undefined;
}
@@ -169,7 +209,6 @@ function saveQuickAuthProviderSettings(input: {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
@@ -185,12 +224,6 @@ function saveQuickAuthProviderSettings(input: {
if (input.baseurl?.trim()) {
nextSettings.baseUrl = input.baseurl.trim();
}
if (input.azureApiVersion?.trim()) {
nextSettings.azure = {
...(nextSettings.azure ?? {}),
apiVersion: input.azureApiVersion.trim(),
};
}
input.providerSettingsManager.saveProviderSettings(nextSettings);
}
@@ -239,18 +272,64 @@ function createOAuthCallbacks(io: AuthIo): {
});
}
async function loginWithOAuthProvider(
providerId: string,
existing: ProviderSettings | undefined,
io: AuthIo,
): Promise<OAuthCredentials> {
const oauthApi = await getCoreOAuthApi();
const callbacks = createOAuthCallbacks(io);
if (providerId === "cline") {
return oauthApi.loginClineOAuth({
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
useWorkOSDeviceAuth: true,
callbacks,
});
}
if (providerId === "oca") {
const mode = existing?.oca?.mode;
return oauthApi.loginOcaOAuth({
mode,
callbacks,
});
}
if (providerId === "openai-codex") {
return oauthApi.loginOpenAICodex(callbacks);
}
throw new Error(
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
);
}
export function saveOAuthProviderSettings(
providerSettingsManager: ProviderSettingsManager,
providerId: string,
existing: ProviderSettings | undefined,
credentials: OAuthCredentials,
): ProviderSettings {
return saveProviderOAuthCredentials({
manager: providerSettingsManager,
providerId,
settings: existing,
credentials,
const auth = {
...(existing?.auth ?? {}),
accessToken: toProviderApiKey(providerId, credentials),
refreshToken: credentials.refresh,
accountId: credentials.accountId,
} as ProviderSettings["auth"] & { expiresAt?: number };
auth.expiresAt = credentials.expires;
const merged: ProviderSettings = {
...(existing ?? {
provider: providerId as ProviderSettings["provider"],
}),
provider: providerId as ProviderSettings["provider"],
auth,
};
providerSettingsManager.saveProviderSettings(merged, {
tokenSource: "oauth",
});
return merged;
}
export async function ensureOAuthProviderApiKey(input: {
@@ -269,14 +348,19 @@ export async function ensureOAuthProviderApiKey(input: {
selectedProviderSettings: input.existingSettings,
};
}
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
const credentials = await loginWithOAuthProvider(
input.providerId,
input.existingSettings,
input.io,
);
const selectedProviderSettings = saveOAuthProviderSettings(
input.providerSettingsManager,
input.providerId,
{ callbacks: createOAuthCallbacks(input.io) },
input.existingSettings,
credentials,
);
const handler = getProviderAuthHandler(input.providerId);
return {
apiKey: handler?.getApiKey(selectedProviderSettings),
apiKey: toProviderApiKey(input.providerId, credentials),
selectedProviderSettings,
};
}
@@ -286,14 +370,12 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
const apikey = input.apikey?.trim() ?? "";
const modelid = input.modelid?.trim() ?? "";
const baseurl = input.baseurl?.trim();
const azureApiVersion = input.azureApiVersion?.trim();
const validationError = await ensureQuickSetupInputValid(
{
provider: providerId,
apikey,
modelid,
baseurl,
azureApiVersion,
},
input.providerSettingsManager,
);
@@ -307,7 +389,6 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
apikey,
modelid,
baseurl,
azureApiVersion,
});
input.io.writeln(
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
@@ -392,13 +473,12 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
const hasQuickSetupFlags =
typeof input.apikey === "string" ||
typeof input.modelid === "string" ||
typeof input.baseurl === "string" ||
typeof input.azureApiVersion === "string";
typeof input.baseurl === "string";
if (hasQuickSetupFlags) {
if (!input.explicitProvider?.trim()) {
input.io.writeErr(
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
);
return 1;
}
@@ -435,15 +515,14 @@ export async function runAuthProviderCommand(
return 1;
}
try {
const settings = await loginAndSaveProviderOAuthCredentials(
const existing = providerSettingsManager.getProviderSettings(providerId);
const credentials = await loginWithOAuthProvider(providerId, existing, io);
saveOAuthProviderSettings(
providerSettingsManager,
providerId,
{ callbacks: createOAuthCallbacks(io) },
existing,
credentials,
);
identifyTelemetryAccount({
id: settings.auth?.accountId,
provider: providerId,
});
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
);
-24
View File
@@ -6,15 +6,6 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"CLINE_DIR",
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
@@ -47,9 +38,6 @@ describe("runDashboardCommand", () => {
let observedEnv:
| {
workspaceRoot: string | undefined;
clineDir: string | undefined;
clineDataDir: string | undefined;
providerSettingsPath: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
@@ -62,9 +50,7 @@ describe("runDashboardCommand", () => {
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
@@ -76,9 +62,6 @@ describe("runDashboardCommand", () => {
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
clineDir: process.env.CLINE_DIR,
clineDataDir: process.env.CLINE_DATA_DIR,
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
@@ -104,13 +87,6 @@ describe("runDashboardCommand", () => {
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
clineDir: "/tmp/cline-config",
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
providerSettingsPath: join(
resolve("sdk", ".cline-dashboard-data"),
"settings",
"providers.json",
),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
+7 -27
View File
@@ -3,7 +3,6 @@ import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import { c } from "../utils/output";
export interface DashboardServerHandle {
@@ -20,9 +19,7 @@ interface DashboardCommandIo {
}
export interface RunDashboardCommandOptions {
configDir?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -39,9 +36,10 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value !== undefined) {
process.env[name] = value;
if (value === undefined) {
return () => {};
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
@@ -51,39 +49,21 @@ function setEnvValue(name: string, value: string | undefined): () => void {
};
}
const SANDBOX_ENV_KEYS = [
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
] as const;
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const restore = [
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
configureSandboxEnvironment({
enabled: true,
cwd,
explicitDir: options.dataDir,
});
}
try {
return await fn();
} finally {
+2 -24
View File
@@ -14,7 +14,6 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
@@ -25,15 +24,6 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
@@ -62,7 +52,6 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
@@ -87,15 +76,6 @@ describe("runDoctorCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -130,8 +110,7 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
args[1] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
@@ -282,8 +261,7 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
args[1] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
+9 -60
View File
@@ -7,11 +7,10 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
@@ -55,7 +54,6 @@ type DoctorStatus = {
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
staleHubPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
@@ -79,11 +77,7 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
// "--" stops pgrep's option parsing so patterns that start with dashes
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
encoding: "utf8",
});
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
if (result.status !== 0 && result.status !== 1) {
return [];
}
@@ -154,25 +148,6 @@ function listStaleCliPids(): number[] {
.map((record) => record.pid);
}
function listStaleHubPids(currentHubPids: number[]): number[] {
const current = new Set(currentHubPids.filter((pid) => pid > 0));
const patterns = [
"/sdk/packages/core/src/hub/daemon/entry.ts",
"/sdk/packages/core/dist/hub/daemon/entry.js",
"--cline-hub-daemon",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
continue;
}
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
@@ -260,7 +235,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
@@ -284,7 +259,7 @@ async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
@@ -316,25 +291,14 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
? await probeHubServer(discovery.url)
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
const listeningPids = listListeningPids(current?.port);
const currentHubPids = [
...(current?.pid ? [current.pid] : []),
...listeningPids,
];
return {
cwd,
hubUrl: current?.url,
@@ -342,8 +306,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids,
staleHubPids: listStaleHubPids(currentHubPids),
listeningPids: listListeningPids(current?.port),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
@@ -425,7 +388,6 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
@@ -450,7 +412,6 @@ export async function runDoctorCommand(
}
if (
before.listeningPids.length > 0 ||
before.staleHubPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
@@ -462,9 +423,7 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
? await stopLocalHubServerGracefully().catch(() => false)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
@@ -472,20 +431,13 @@ export async function runDoctorCommand(
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleHubTargets = before.staleHubPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedStaleHubs = killPids(staleHubTargets);
const staleCliTargets = before.staleCliPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid),
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
@@ -507,7 +459,6 @@ export async function runDoctorCommand(
after,
killed: {
hubListeners: killedHub,
staleHubDaemons: killedStaleHubs,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
@@ -520,7 +471,6 @@ export async function runDoctorCommand(
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
@@ -537,7 +487,6 @@ export async function runDoctorCommand(
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub startup locks",
-39
View File
@@ -313,45 +313,6 @@ describe("runHistoryExport", () => {
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("exports run_commands history with structured command objects", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const outputPath = join(tempDir, "export.html");
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
messages: [
{
id: "m1",
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "run_commands",
input: {
commands: [{ command: "cmd", args: ["/c", "dir"] }],
},
},
],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const code = await runHistoryExport("sess_1", outputPath, "text", io);
expect(code).toBe(0);
expect(io.writeErr).not.toHaveBeenCalled();
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
});
it("fails when the session artifact is missing", async () => {
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
const io = {
+1 -51
View File
@@ -1,11 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
@@ -13,10 +12,6 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
@@ -29,25 +24,13 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
});
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
@@ -90,37 +73,4 @@ describe("createHubCommand", () => {
uptime: "1m 5s",
});
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
port: 25466,
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(true);
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
});
+5 -14
View File
@@ -3,11 +3,10 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
@@ -16,9 +15,9 @@ interface HubCommandIo {
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully(owner)) {
if (await stopLocalHubServerGracefully()) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -47,12 +46,6 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -119,12 +112,10 @@ export function createHubCommand(
hub.command("status").action(
action(async () => {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
})
? await probeHubServer(discovery.url)
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
-6
View File
@@ -168,8 +168,6 @@ export function buildKanbanSpawnOptions(
detached: shouldDetachKanbanProcess(platform),
...(platform === "win32" ? { shell: true } : {}),
...options,
// Prevent a console window from flashing on Windows.
windowsHide: true,
};
}
@@ -180,8 +178,6 @@ function buildKanbanInstallSpawnOptions(
return {
detached: false,
stdio: "inherit",
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(platform === "win32" ? { shell: true } : {}),
...options,
};
@@ -207,8 +203,6 @@ export function getInstalledKanbanVersion(): string | null {
const result = spawnSync(getKanbanCommand(), ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
if (result.status !== 0) {
return null;
-271
View File
@@ -1,271 +0,0 @@
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
installMcpServer: vi.fn((options) => {
const { name, transport, warnings } =
actual.buildMcpInstallTransport(options);
return {
name,
status: "installed",
transport,
warnings,
};
}),
};
});
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
expect(
buildMcpInstallDefaults({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
type: "stdio",
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
});
});
it("builds remote wizard defaults and normalizes http transport", () => {
expect(
buildMcpInstallDefaults({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
}),
).toEqual({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
name: "docs",
transport: "streamable-http",
targetArgs: ["https://example.com/mcp"],
}),
).toEqual({
name: "docs",
type: "streamableHttp",
url: "https://example.com/mcp",
});
});
it("builds SSE wizard defaults", () => {
expect(
buildMcpInstallDefaults({
name: "events",
transport: "sse",
targetArgs: ["https://example.com/sse"],
}),
).toEqual({
name: "events",
type: "sse",
url: "https://example.com/sse",
});
});
it("rejects missing stdio command and invalid remote URL", () => {
expect(() =>
buildMcpInstallDefaults({
name: "fs",
}),
).toThrow(/requires a command/);
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["not-a-url"],
}),
).toThrow(/Invalid MCP server URL/);
});
it("rejects remote URL schemes other than http and https", () => {
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["file:///etc/passwd"],
}),
).toThrow(/only http and https are supported/);
});
it("builds direct stdio installs without shell-joining args", () => {
expect(
buildMcpInstallTransport({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
transport: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
},
warnings: [],
});
});
it("builds direct remote installs with headers and placeholder warnings", () => {
expect(
buildMcpInstallTransport({
name: "docs",
transport: "http",
headers: ["Authorization: Bearer <token>"],
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
}),
).toEqual({
name: "docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer <token>",
"X-Extra": "yes",
},
},
warnings: [
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
],
});
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: true,
runWizard,
io: { writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(runWizard).toHaveBeenCalledWith({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("requires a TTY because it opens the wizard", async () => {
const writeErr = vi.fn();
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: false,
runWizard,
io: { writeErr },
});
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("checks for TTY before validating wizard install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
isTty: false,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("installs directly with --yes without requiring a TTY", async () => {
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
});
it("prints direct install JSON with --yes --json", async () => {
const writeln = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
targetArgs: ["node", "server.js"],
isTty: false,
yes: true,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
name: "fs",
status: "installed",
transport: {
type: "stdio",
command: "node",
args: ["server.js"],
},
});
});
});
-160
View File
@@ -1,160 +0,0 @@
import {
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
writeErr: (text: string) => void;
}
export interface McpInstallOptions extends CoreMcpInstallOptions {
io?: McpCommandIo;
isTty?: boolean;
json?: boolean;
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
yes?: boolean;
}
export interface McpInstallDirectResult {
name: string;
status: "installed";
transport: McpServerTransportConfig;
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
}
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
}
export function buildMcpInstallDefaults(options: {
name: string;
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
};
}
export function installMcpServerDirect(
options: McpInstallOptions,
): McpInstallDirectResult {
const result: McpInstallResult = installMcpServer(options);
return {
name: result.name,
status: result.status,
transport: result.transport,
warnings: result.warnings,
};
}
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
const { runMcpWizard } = await import("../wizards/mcp");
return runMcpWizard({
initialAction: "add",
addDefaults: defaults,
exitAfterInitialAction: true,
});
}
export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
if (options.yes) {
const result = installMcpServerDirect(options);
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
for (const warning of result.warnings) {
options.io?.writeErr(warning);
}
}
return 0;
}
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
}
const defaults = buildMcpInstallDefaults(options);
return await (options.runWizard ?? runPrefilledWizard)(defaults);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+1 -348
View File
@@ -17,7 +17,6 @@ import {
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
@@ -36,7 +35,6 @@ describe("plugin install command", () => {
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
let originalMcpSettingsPath: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
@@ -45,7 +43,6 @@ describe("plugin install command", () => {
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
@@ -94,11 +91,6 @@ describe("plugin install command", () => {
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
rmSync(root, { recursive: true, force: true });
});
@@ -247,10 +239,6 @@ describe("plugin install command", () => {
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"official-web-search",
);
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string };
expect(wrapperManifest.name).toBe("web-search");
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
expect(
existsSync(join(result.installPath, "package", "other-plugin")),
@@ -340,10 +328,6 @@ describe("plugin install command", () => {
expect(result.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "local"),
);
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string };
expect(wrapperManifest.name).toBe("web-search");
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"local-web-search",
);
@@ -472,8 +456,7 @@ describe("plugin install command", () => {
const wrapperManifest = JSON.parse(
readFileSync(join(result.installPath, "package.json"), "utf8"),
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
expect(wrapperManifest.name).toBe("plugin-package");
) as { cline?: { plugins?: Array<{ paths?: string[] }> } };
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
"package/index.ts",
@@ -684,341 +667,11 @@ describe("plugin install command", () => {
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect("mcpOAuthCandidates" in parsed).toBe(false);
} finally {
process.stdout.write = originalWrite;
}
});
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "json-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "json-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "json-oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
const authorize = vi.fn();
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
const parsed = JSON.parse(stdout.join("")) as {
installPath: string;
mcpOAuthCandidates?: unknown;
};
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect(parsed.mcpOAuthCandidates).toBeUndefined();
} finally {
process.stdout.write = originalWrite;
}
});
it("warns when plugin MCP settings sync fails after install", async () => {
const source = join(root, "mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "mcp-plugin",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const blockedDirectory = join(root, "not-a-directory");
writeFileSync(blockedDirectory, "file", "utf8");
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = join(
blockedDirectory,
"cline_mcp_settings.json",
);
const output: string[] = [];
try {
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain("Installed plugin from");
expect(output.join("\n")).toContain(
"Warning: failed to sync plugin MCP servers",
);
expect(output.join("\n")).toContain("mcp-plugin");
} finally {
if (originalSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
}
}
});
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([
expect.objectContaining({
name: "oauth-docs",
pluginName: "oauth-mcp-plugin",
transportType: "streamableHttp",
}),
]);
});
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "headers-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "headers-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "headers-docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([]);
});
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
const settingsPath = join(root, "mcp-settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const source = join(root, "authorized-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "authorized-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "authorized-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, { oauth?: unknown }>;
};
const server = settings.mcpServers?.["authorized-docs"];
if (!server) {
throw new Error("Expected authorized-docs MCP server to be written");
}
server.oauth = { tokens: { access_token: "oauth-token" } };
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
expect(
collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
settingsPath,
}),
).toEqual([]);
});
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const authorized: string[] = [];
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async (candidate) => {
authorized.push(candidate.name);
},
},
});
expect(code).toBe(0);
expect(authorized).toEqual(["interactive-docs"]);
expect(output.join("\n")).toContain("Installed plugin from");
});
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "failing-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "failing-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "failing-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async () => {
throw new Error("oauth unavailable");
},
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain(
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
);
});
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "non-interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "non-interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "non-interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const authorize = vi.fn();
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: false,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
expect(output.join("\n")).toContain(
"Plugin MCP servers may require OAuth authorization",
);
expect(output.join("\n")).toContain("non-interactive-docs");
expect(output.join("\n")).toContain('Run "cline mcp"');
});
it("prints JSON output for official plugin installs", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"json-plugin": {
File diff suppressed because it is too large Load Diff
+2 -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(
@@ -116,6 +116,7 @@ export function createProgram(): Command {
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
@@ -78,74 +78,6 @@ describe("saveLocalProviderSettings", () => {
);
});
it("merges and clears Azure provider settings", () => {
const save = vi.fn();
const manager = {
read: vi.fn().mockReturnValue({
providers: {},
}),
write: vi.fn(),
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
getProviderSettings: vi.fn().mockReturnValue({
provider: "openai-compatible",
azure: {
apiVersion: "2024-10-21",
useIdentity: true,
},
}),
saveProviderSettings: save,
};
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
},
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith(
{
provider: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
useIdentity: true,
},
},
{ setLastUsed: false },
);
save.mockClear();
manager.getProviderSettings.mockReturnValue({
provider: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
},
});
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai-compatible",
azure: {
apiVersion: "",
},
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledWith(
{
provider: "openai-compatible",
},
{ setLastUsed: false },
);
});
it("keeps OAuth auth fields when updating manual apiKey", () => {
const save = vi.fn();
const manager = {
-88
View File
@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"add",
"owner/repo",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
);
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
});
});
-160
View File
@@ -1,160 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
export interface SkillCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
* Build the argument list passed to `npx`, injecting `--agent cline` for
* install-style subcommands unless the user already targeted an agent.
*/
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
!hasAgentFlag(args)
) {
args.push("--agent", "cline");
}
return ["-y", SKILLS_PACKAGE, ...args];
}
function resolveExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
/**
* Forward all arguments to the open skills CLI via `npx skills`.
*
* Returns the child process exit code, or 1 if npx is unavailable or fails to
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
* pass straight through to the user's terminal.
*/
export async function runSkillCommand(
userArgs: readonly string[],
io: SkillCommandIo,
): Promise<number> {
const args = buildSkillsArgs(userArgs);
const isWindows = process.platform === "win32";
const options: SpawnOptions = {
stdio: "inherit",
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(isWindows ? { shell: true } : {}),
};
return new Promise<number>((resolve) => {
const child = spawn("npx", args, options);
const forward = (signal: NodeJS.Signals) => {
child.kill(signal);
};
const handleSigint = () => forward("SIGINT");
const handleSigterm = () => forward("SIGTERM");
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
const cleanup = () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
child.once("error", (error: NodeJS.ErrnoException) => {
cleanup();
if (error.code === "ENOENT") {
io.writeErr(
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
);
} else {
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
}
resolve(1);
});
child.once("close", (code, signal) => {
cleanup();
resolve(resolveExitCode(code, signal));
});
});
}
+5 -169
View File
@@ -1,24 +1,15 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
autoUpdateOnStartup,
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createFile(path: string): string {
@@ -36,42 +27,11 @@ function createTempFile(pathSuffix: string): string {
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
@@ -85,7 +45,7 @@ describe("getInstallationInfo", () => {
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag latest",
updateCommand: "npm install -g cline@latest",
});
});
@@ -97,23 +57,7 @@ describe("getInstallationInfo", () => {
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag nightly",
});
});
it("detects bun global installs from the resolved install path", () => {
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
// and realpathSync resolves through the symlink before detection runs.
const wrapperPath = createTempFile(
".bun/install/global/node_modules/cline/bin/cline",
);
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.BUN,
packageName: "cline",
updateCommand: "bun add -g cline@latest",
updateCommand: "npm install -g cline@nightly",
});
});
@@ -128,122 +72,14 @@ describe("getInstallationInfo", () => {
});
});
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("skips startup auto update when disabled globally", () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.IS_DEV;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("should not fetch"));
autoUpdateOnStartup();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("still lets manual update checks run when startup auto update is disabled", async () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.0" }),
} as Response);
await checkForUpdates({ includeKanban: false });
expect(fetchSpy).toHaveBeenCalled();
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
withMinimumReleaseAgeBypass(
"npm update -g cline --tag latest",
"npm install -g cline@latest",
PackageManager.NPM,
).command,
).toBe("npm update -g cline --tag latest --min-release-age=0");
).toBe("npm install -g cline@latest --min-release-age=0");
expect(
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
.command,
+12 -42
View File
@@ -2,14 +2,11 @@ import { type ChildProcess, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import {
clearHubDiscovery,
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -118,12 +115,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
// them to ~/.bun/install/global/node_modules/..., so match both.
if (
scriptPath.includes("/.bun/bin") ||
scriptPath.includes("/.bun/install/global/")
) {
if (scriptPath.includes("/.bun/bin")) {
return {
packageManager: PackageManager.BUN,
packageName: DEFAULT_PACKAGE_NAME,
@@ -134,7 +126,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
return {
packageManager: PackageManager.NPM,
packageName: DEFAULT_PACKAGE_NAME,
updateCommand: `npm update -g ${DEFAULT_PACKAGE_NAME} --tag ${tag}`,
updateCommand: `npm install -g ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
} catch {
@@ -276,22 +268,13 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
const check = await probeHubServer(url).catch(() => undefined);
if (!check?.url) return true;
await sleep(100);
}
@@ -304,22 +287,20 @@ async function waitForHubToStop(
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const health = discovery?.url
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
? await probeHubServer(discovery.url).catch(() => undefined)
: undefined;
if (!discovery || !health?.url) return;
if (!health?.url) return;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
let stopped = await stopLocalHubServerGracefully().catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
@@ -328,14 +309,14 @@ async function restartHubServerIfRunning(): Promise<void> {
}
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
stopped = await waitForHubToStop(health.url, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
stopped = await waitForHubToStop(health.url, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
@@ -359,30 +340,19 @@ async function restartHubServerIfRunning(): Promise<void> {
export function autoUpdateOnStartup(): void {
if (process.env.IS_DEV === "true") return;
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
if (!isAutoUpdateEnabledGlobally()) return;
const { packageName, packageManager, updateCommand } =
getInstallationInfo(version);
const { packageName, updateCommand } = getInstallationInfo(version);
if (!updateCommand) return;
void (async () => {
try {
const latest = await getLatestVersion(packageName, version);
if (!latest || compareVersions(version, latest) >= 0) return;
const autoUpdateCommand = withMinimumReleaseAgeBypass(
updateCommand,
packageManager,
);
const child = spawn(autoUpdateCommand.command, {
const child = spawn(updateCommand, {
shell: true,
detached: true,
stdio: "ignore",
env: autoUpdateCommand.env
? { ...process.env, ...autoUpdateCommand.env }
: process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
env: process.env,
});
const exitCode = await waitForProcessExit(child);
if (exitCode === 0) {
@@ -228,7 +228,7 @@ describe("discordConnector", () => {
});
});
it("updates Discord participant metadata without changing the thread session", async () => {
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
const bindingsPath = join(dir, "threads.json");
const thread = createThread({
@@ -278,13 +278,11 @@ describe("discordConnector", () => {
errorLabel: "Discord",
});
const binding =
readBindings<TestDiscordState>(bindingsPath)[
"discord:guild:channel:thread"
];
expect(binding?.state?.participantKey).toBe("discord:user:bob");
expect(binding?.state?.participantLabel).toBe("Bob");
expect(binding?.state?.sessionId).toBe("session-alice");
const bob =
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
expect(bob?.state?.participantKey).toBe("discord:user:bob");
expect(bob?.state?.participantLabel).toBe("Bob");
expect(bob?.state?.sessionId).toBeUndefined();
expect(
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
?.sessionId,
+49 -16
View File
@@ -50,9 +50,10 @@ import {
type ConnectorMuteTarget,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
mergeThreadState,
persistMergedThreadState,
readBindings,
} from "../thread-bindings";
@@ -563,17 +564,45 @@ async function postDiscordResolvedText(input: {
});
}
function resolveCurrentStateWithParticipant(input: {
currentState: DiscordThreadState;
function resolveParticipantState(input: {
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
participant: DiscordParticipant;
}): DiscordThreadState {
const existing = findBindingForParticipantKey(
readBindings<DiscordThreadState>(input.bindingsPath),
input.participant.key,
)?.binding.state;
return {
...input.currentState,
...mergeThreadState<DiscordThreadState>(
undefined,
existing,
input.baseStartRequest,
),
participantKey: input.participant.key,
participantLabel: input.participant.label,
};
}
function resolveCurrentStateWithParticipant(input: {
currentState: DiscordThreadState;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
participant: DiscordParticipant;
}): DiscordThreadState {
if (input.currentState.participantKey === input.participant.key) {
return {
...input.currentState,
participantLabel: input.participant.label,
};
}
return resolveParticipantState({
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant: input.participant,
});
}
async function persistDiscordThreadContext(input: {
thread: Thread<DiscordThreadState>;
bindingsPath: string;
@@ -595,6 +624,8 @@ async function persistDiscordThreadContext(input: {
);
const nextState = resolveCurrentStateWithParticipant({
currentState,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant,
});
if (
@@ -638,20 +669,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
return;
}
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -1102,7 +1133,9 @@ class DiscordConnector extends ConnectorBase<
isSubscribedThreadMessage?: boolean;
},
) => {
const queueKey = thread.id;
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
+15 -4
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { __test__ } from "./gchat";
describe("gchat binding lookup", () => {
it("does not fall back to channel identity for a different space thread id", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
@@ -21,7 +21,17 @@ describe("gchat binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "space-123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
});
it("prefers an exact thread id match over a channel fallback", () => {
@@ -55,7 +65,7 @@ describe("gchat binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different spaces", () => {
it("reuses a binding by participant key across different spaces", () => {
const result = __test__.findBindingForThread(
{
"gchat:email:alice@example.com": {
@@ -81,6 +91,7 @@ describe("gchat binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result?.key).toBe("gchat:email:alice@example.com");
expect(result?.binding.sessionId).toBe("sess-1");
});
});
+15 -13
View File
@@ -46,7 +46,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -191,20 +191,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
return;
}
const bindings = readBindings<GoogleChatThreadState>(input.bindingsPath);
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -590,7 +590,9 @@ class GoogleChatConnector extends ConnectorBase<
thread: Thread<GoogleChatThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { __test__ } from "./linear";
describe("linear binding lookup", () => {
it("does not fall back to channel identity for a different issue thread id", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
@@ -21,7 +21,17 @@ describe("linear binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "linear:issue:ISS-123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
});
it("prefers an exact thread id match over a channel fallback", () => {
@@ -55,7 +65,7 @@ describe("linear binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different issue threads", () => {
it("reuses a binding by participant key across different issue threads", () => {
const result = __test__.findBindingForThread(
{
"linear:user:user_123": {
@@ -81,6 +91,7 @@ describe("linear binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result?.key).toBe("linear:user:user_123");
expect(result?.binding.sessionId).toBe("sess-1");
});
});
+15 -13
View File
@@ -42,7 +42,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -229,20 +229,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
return;
}
const bindings = readBindings<LinearThreadState>(input.bindingsPath);
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -625,7 +625,9 @@ class LinearConnector extends ConnectorBase<
thread: Thread<LinearThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
+1 -1
View File
@@ -29,7 +29,7 @@ export function getConnectorSystemRules(
}
const CONNECTOR_FIRST_CONTACT_MESSAGE = [
"Connected to Cline.",
"Connected.",
"Your chat history is kept separately for your account.",
"Send /new to start a fresh session or /whereami for thread details.",
].join("\n");
+7 -106
View File
@@ -1,5 +1,4 @@
import type { ConnectSlackOptions } from "@cline/shared";
import { type Message, ThreadImpl } from "chat";
import { describe, expect, it } from "vitest";
import { __test__, slackConnector } from "./slack";
@@ -63,12 +62,12 @@ describe("slack binding lookup", () => {
expect(options.appToken).toBe("xapp-token");
});
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
channelId: "slack:C123",
isDM: true,
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
@@ -78,7 +77,7 @@ describe("slack binding lookup", () => {
{
id: "new_thread_id",
channelId: "slack:C123",
isDM: true,
isDM: false,
},
);
@@ -86,7 +85,7 @@ describe("slack binding lookup", () => {
key: "legacy_thread_id",
binding: {
channelId: "slack:C123",
isDM: true,
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
@@ -126,7 +125,7 @@ describe("slack binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different threads", () => {
it("reuses a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
@@ -153,7 +152,8 @@ describe("slack binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result?.key).toBe(participantKey);
expect(result?.binding.sessionId).toBe("sess-1");
});
it("builds Slack participant keys with a team scope", () => {
@@ -215,105 +215,6 @@ describe("slack binding lookup", () => {
);
});
it("normalizes top-level channel mentions to the original Slack post thread", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> help",
ts: "1710000000.123456",
type: "app_mention",
user: "U123",
},
} as Message;
const normalized = __test__.resolveSlackChannelMentionThread(
original,
message,
);
expect(normalized.id).toBe("slack:C123:1710000000.123456");
expect(normalized.channelId).toBe("slack:C123");
expect(normalized.isDM).toBe(false);
});
it("uses Slack thread_ts instead of reply ts for in-thread mentions", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:1710000001.654321",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> follow up",
thread_ts: "1710000000.123456",
ts: "1710000001.654321",
type: "app_mention",
user: "U123",
},
} as Message;
const normalized = __test__.resolveSlackChannelMentionThread(
original,
message,
);
expect(normalized.id).toBe("slack:C123:1710000000.123456");
expect(normalized.channelId).toBe("slack:C123");
expect(normalized.isDM).toBe(false);
});
it("keeps Slack mention threads that already target the original post", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:1710000000.123456",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> help",
ts: "1710000000.123456",
type: "app_mention",
user: "U123",
},
} as Message;
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
original,
);
});
it("does not rewrite Slack DM mention threads", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:D123",
id: "slack:D123:",
isDM: true,
});
const message = {
raw: {
channel: "D123",
text: "help",
ts: "1710000000.123456",
type: "message",
user: "U123",
},
} as Message;
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
original,
);
});
it("routes Slack posts through the installation bot token for a team", async () => {
const calls: string[] = [];
const result = await __test__.withSlackTeamBotToken({
+17 -70
View File
@@ -9,7 +9,6 @@ import {
type Adapter,
Chat,
ConsoleLogger,
type Message,
type Thread,
ThreadImpl,
} from "chat";
@@ -51,7 +50,7 @@ import {
type ConnectorThreadBinding,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -193,56 +192,6 @@ function extractSlackTeamId(raw: unknown): string | undefined {
return value?.trim() || undefined;
}
function extractSlackMessageRecord(
raw: unknown,
): Record<string, unknown> | undefined {
const record = asRecord(raw);
return asRecord(record?.event) ?? asRecord(record?.message) ?? record;
}
function extractSlackChannelFromId(id: string): string | undefined {
const parts = id.split(":");
return parts[0] === "slack" ? readString(parts[1]) : undefined;
}
function resolveSlackChannelMentionThread(
thread: Thread<SlackThreadState>,
message: Message,
): Thread<SlackThreadState> {
if (thread.isDM) {
return thread;
}
const event = extractSlackMessageRecord(message.raw);
const threadTs = readString(event?.thread_ts) ?? readString(event?.ts);
if (!threadTs) {
return thread;
}
const channel =
readString(event?.channel) ??
extractSlackChannelFromId(thread.id) ??
extractSlackChannelFromId(thread.channelId);
if (!channel) {
return thread;
}
const threadId = `slack:${channel}:${threadTs}`;
const channelId = `slack:${channel}`;
if (thread.id === threadId && thread.channelId === channelId) {
return thread;
}
return new ThreadImpl<SlackThreadState>({
adapterName: "slack",
channelId,
channelVisibility: thread.channelVisibility,
currentMessage: message,
fallbackStreamingPlaceholderText: null,
id: threadId,
initialMessage: message,
isDM: false,
isSubscribedContext: false,
streamingUpdateIntervalMs: 500,
});
}
async function withSlackBindingBotToken<T>(input: {
slack: Pick<SlackAdapter, "getInstallation" | "withBotToken">;
binding: ConnectorThreadBinding<SlackThreadState>;
@@ -376,20 +325,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
return;
}
const bindings = readBindings<SlackThreadState>(input.bindingsPath);
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const binding = match?.binding;
const deliveryThreadId = match?.key || threadId || bindingKey;
if (!binding?.serializedThread) {
@@ -826,7 +775,7 @@ class SlackConnector extends ConnectorBase<
bindingsPath,
startRequest,
);
const queueKey = thread.id;
const queueKey = currentState.participantKey || thread.id;
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -945,10 +894,9 @@ class SlackConnector extends ConnectorBase<
};
bot.onNewMention(async (thread, message) => {
const mentionThread = resolveSlackChannelMentionThread(thread, message);
await mentionThread.subscribe();
await thread.subscribe();
await persistSlackThreadContext({
thread: mentionThread,
thread,
bindingsPath,
baseStartRequest: startRequest,
rawMessage: message.raw,
@@ -956,7 +904,7 @@ class SlackConnector extends ConnectorBase<
});
if (
await maybeHandleConnectorApprovalReply({
thread: mentionThread,
thread,
text: message.text,
client,
clientId,
@@ -966,7 +914,7 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(mentionThread, message.text);
await handleTurn(thread, message.text);
});
bot.onSubscribedMessage(async (thread, message) => {
@@ -1202,7 +1150,6 @@ export const __test__ = {
buildSlackParticipantKey,
resolveSlackParticipant,
normalizeSlackMessageEventChannelType,
resolveSlackChannelMentionThread,
withSlackTeamBotToken,
isSlackInvalidThreadTsError,
findBindingForThread: (
@@ -363,7 +363,7 @@ describe("telegram binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different chats", () => {
it("reuses a binding by participant key across different chats", () => {
const result = __test__.findBindingForThread(
{
"telegram:user:alice": {
@@ -389,6 +389,7 @@ describe("telegram binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result?.key).toBe("telegram:user:alice");
expect(result?.binding.sessionId).toBe("sess-1");
});
});
+15 -13
View File
@@ -42,7 +42,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
findBindingForParticipantKey,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -293,20 +293,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
return;
}
const bindings = readBindings<TelegramThreadState>(input.bindingsPath);
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const binding = match?.binding;
const deliveryThreadId = match?.key || threadId;
if (!binding?.serializedThread) {
@@ -788,7 +788,9 @@ class TelegramConnector extends ConnectorBase<
thread: Thread<TelegramThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -65,7 +65,7 @@ describe("whatsapp binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different threads", () => {
it("reuses a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
"whatsapp:user:15551234567": {
@@ -91,6 +91,7 @@ describe("whatsapp binding lookup", () => {
},
);
expect(result).toBeUndefined();
expect(result?.key).toBe("whatsapp:user:15551234567");
expect(result?.binding.sessionId).toBe("sess-1");
});
});

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