Compare commits

..

93 Commits

Author SHA1 Message Date
Saoud Rizwan 626d82c56f docs(vscode): add SDK migration audit and bare-bones demolition record
Document the target architecture (CLI as the reference SDK consumer), what was
removed in the provider-settings consolidation and the host demolition, the
demolition seam and survivor list, verification status and caveats, and how to
rebuild the host on the SDK.
2026-06-21 13:23:15 -07:00
Saoud Rizwan c4c126bee9 refactor(vscode)!: demolish extension host to bare-bones inert SDK shell
Gut the VSCode extension host down to a minimal, inert shell so it can be rebuilt
from near-zero on the Cline SDK, the way apps/cli is. The webview UI is kept fully
intact (it renders and you can click around), but every backend action is now a
no-op.

Approach: the webview talks to the host only through generated proto service
clients over a postMessage bridge and never imports handler implementations. So
each gRPC handler under core/controller was gutted to an inert stub that returns
an empty/default proto response and imports nothing downstream, severing the
handler layer from all implementation. With nothing left referencing it, the
implementation was deleted; the shell (extension.ts, common.ts, Controller,
hosts/) was rewritten to the minimum needed to render the webview and route gRPC.

Deleted entirely: src/sdk, src/services, src/integrations,
src/core/{task,context,hooks,storage,prompts,mentions,ignore,locks}, most of
src/hosts/vscode (terminal, diff, review, commit-message generation), and all
host-side tests.

Survived: the gRPC plumbing + gutted handlers, a minimal inert Controller, the
webview provider, src/shared (incl. proto types), and src/utils. extension.ts
shrank from 760 to 106 lines.

Verified: 'bun run protos && tsc --noEmit' is clean for the host and
'tsc --noEmit' is clean for the webview. NOTE: only typechecking is verified --
the bundle builds (esbuild/vite) and a real Extension Development Host launch have
not been run yet, and package.json still declares commands whose handlers were
removed.

BREAKING CHANGE: the extension is intentionally non-functional; this is a
foundation for an SDK-backed rebuild, not a shippable state.
2026-06-21 13:23:10 -07:00
Saoud Rizwan 2807b088e5 refactor(vscode): route Groq/Baseten/Vercel settings through generic SDK path
Collapse the bespoke Groq, Baseten, and Vercel AI Gateway provider settings
components (and their dedicated model pickers) into the shared, catalog-driven
GenericProviderSettings path. These providers are added to the static fallback
map so the generic UI always renders, even before SDK provider listings load.

This is the first slice of de-duplicating the extension against the SDK: per
the CLI's design, provider settings UIs should be driven by SDK provider/catalog
metadata rather than one hand-written component per provider.
2026-06-21 13:22:03 -07:00
Max 0c39279a49 fix(vscode): use Codex OAuth credentials (#11691) 2026-06-20 08:20:07 -07:00
BarreiroT d1faa633bd Fix tests 2026-06-20 11:34:56 -03:00
BarreiroT 4623cd3e46 fix tests 2026-06-19 21:08:14 -03:00
Tomás Barreiro b314df51c2 Fix ClinePass auth (#11680)
* Return local providers with ClinePass in the new extension

* Fix import

* Fix ClinePass auth
2026-06-20 01:32:23 +02:00
Tomás Barreiro 4f12662ee0 Return local providers with ClinePass in the new extension (#11678)
* Return local providers with ClinePass in the new extension

* Fix import
2026-06-19 23:58:39 +02:00
Max Paulus 🥪 f1c81047eb fix broken CI tests 2026-06-19 13:17:47 -07:00
Max Paulus 🥪 78c97ee124 fix local build not picking up .env file 2026-06-19 12:04:02 -07:00
Max Paulus 🥪 527f2af2e3 update vscode ignore
vsix bundling failed because of some unignored files
2026-06-19 10:13:56 -07:00
Max Paulus 🥪 0056b08300 fix broken integ tests 2026-06-19 10:13:55 -07:00
Max Paulus 🥪 035cce0ac5 remove storage tests from vitest
- these run under node resolution so they can see "bun:test" imports.
- these tests will get run by scripts/run-bun-unit-tests.ts instead
2026-06-19 10:13:55 -07:00
Cline Agent ef76b1b977 fix: repair SDK ClinePass webview rebase
Restore the webview feature-flag hook needed by the ClinePass onboarding/settings UI, but implement it against the existing posthog singleton instead of posthog-js/react so tests do not pull in a second React copy.

Make ClinePass settings follow the SDK provider-catalog pattern: render the Cline account card, resolve models with useProviderModels("cline-pass"), and persist selections with useProviderConfig/useProviderModelSelection for providerId="cline-pass". Remove the stale origin/main props that tried to drive the SDK-era ClineModelPicker, which is intentionally Cline-provider specific.

ClinePass remains hidden by the ext-cline-pass flag in settings/onboarding, and its model info hides token usage costs because billing is subscription-based.
2026-06-19 09:58:09 -04:00
Cline Agent 0e1da42925 fix: post-rebase ClinePass plumbing for SDK migration
Resolve type-check and test breakages from rebasing the ClinePass
feature (origin/main) onto the SDK migration branch:

- provider-keys: re-add cline-pass to ProviderKeyMap and
  NON_SDK_PROVIDER_DEFAULTS (removed by the 'remove unused code'
  commit which predated ClinePass), so getProviderModelIdKey and
  getProviderDefaultModelId handle the cline-pass provider.
- provider-id: register 'cline-pass' in KNOWN_API_PROVIDERS so the
  Record<ApiProvider, true> constraint is satisfied.
- refreshClineRecommendedModels: add optional 'clinePass' field to
  ClineRecommendedModelsData so the RPC handler can map it into the
  proto response without a type error.
- refreshClineRecommendedModelsRpc: guard models.clinePass with ?? []
  for the same reason.
- handleClinePassProviderSelection: pass undefined (not null) to
  accountService.switchAccount to match the SDK signature.
- provider-keys.test: remove a duplicate closing brace left by the
  conflict resolution.
- Biome formatting (asNeeded semicolons) applied by check-types.
2026-06-18 22:45:48 -04:00
Dominic Cooney dcf78364ca fix(vscode): reliable MCP OAuth on the SDK extension (ENG-2108, CLINE-2304) (#11529)
* fix(vscode): store MCP OAuth in shared settings file like the CLI (ENG-2108)

VSCode stored MCP OAuth tokens in a single mcpOAuthSecrets secrets blob
keyed by sha256(name:url), while the CLI/SDK store per-server oauth state in
cline_mcp_settings.json. The two never interoperated (CLI auth was invisible to
VSCode), and VSCode's read-whole-blob/write-whole-blob through StateManager's
non-refreshing cache meant concurrent windows clobbered each other's tokens.

- Store MCP OAuth state in the shared settings file in @cline/core's format.
- Reads are fresh from disk; writes are scoped read-modify-write of one
  server's oauth key via updateMcpServerOAuthState (now atomic temp+rename).
- Replace the vscode:// callback flow with HTTP-based token collection via
  authorizeMcpServerOAuth (same local loopback flow the CLI uses).
- Reconnect an unauthenticated server when its tokens appear (e.g. CLI auth).
- One-time migration of legacy mcpOAuthSecrets tokens into the shared file.
- Remove McpOAuthRedirectResolver, mcpOAuthFlow, completeOAuth, and the
  mcp-auth URI callback route.

* feat(vscode): add --instances/--random-port to MCP OAuth test server

Lets you start several independent test servers, each on its own OS-assigned
random port, so you can add multiple streamableHttp MCP servers to Cline at
once and exercise concurrent OAuth flows. baseUrl now reflects the actually
bound port so discovery metadata and redirect URIs stay correct under random
ports.

* fix(vscode): stop MCP OAuth handshake writes from livelocking the settings watcher (ENG-2108)

Now that codeVerifier/clientInformation live in the shared settings file, the
MCP SDK's per-connect-attempt saveCodeVerifier() writes were tripping the
settings watcher, which re-entered updateServerConnections -> connectToServer
-> another write, looping forever. It was especially bad with two+
unauthenticated servers, where each server's verifier churn re-triggered the
other (visible as a flickering, ever-changing codeVerifier nonce).

The watcher now compares a connection-relevant fingerprint (full per-server
config minus the oauth block, plus a boolean for whether an access token
exists) and skips writes that only churn OAuth-handshake fields. A token
appearing/disappearing still changes the fingerprint, so CLI/other-window
authorization continues to trigger a reconnect via serverGainedOAuthTokens.

* feat(vscode): print paste-ready MCP settings fragment from OAuth test server

On startup the test server now emits an mcpServers JSON fragment (nested
transport shape, matching cline_mcp_settings.json) alongside the banner, so you
can paste it straight into the settings file instead of hand-writing it. With
--instances the entries get distinct names (oauth-test-1, ...), each carrying
its actual bound port.

* fix(vscode): atomic MCP settings writes + fingerprint gate; drop timer guards (CLINE-2097)

Deleting one MCP server could empty the whole list. Root cause: settings
writes were non-atomic (fs.writeFile), so chokidar (and any other process)
could read a transient empty/torn file mid-write and reconcile to zero servers.
The previous fix only masked this with a per-process isUpdatingClineSettings
boolean cleared on a 300ms timer — it did nothing for the CLI or other windows
and was racy.

Replace both timer guards (isUpdatingClineSettings, isUpdatingFromRemoteConfig)
with two deterministic, process-agnostic mechanisms:

- writeSettingsFile(): atomic temp-file + rename for every settings write, so
  any reader always sees a complete file. Holds for any number of concurrent
  writers (CLI, multiple windows, SDK OAuth handshake).
- content fingerprint: the watcher reconciles only when the connection-relevant
  view changed. writeSettingsFile pre-seeds the fingerprint so our own write is
  a no-op, while a genuine change from any other process is still processed.
  Because reconcile is idempotent and reads are never torn, a missed
  suppression is at worst a redundant reconnect, never data loss.

All RPC writers (toggle disabled, autoApprove x2, timeout, add, delete) and the
remote-config sync now go through writeSettingsFile. Removes all setTimeout(.,
300) flag juggling.

* feat(vscode): add a non-guessable 'frozzle' tool to the MCP OAuth test server

The MCP OAuth test server now serves tools/list + tools/call exposing a
'frozzle' tool whose output cannot be derived without calling it (reverse the
string and swap each letter's case, wrapped in guillemets). This gives an eval
a reliable end-to-end signal that the OAuth-authenticated MCP round-trip really
happened: a correct 'frozzle <text>' answer can't be hallucinated. The
transform is easy to verify at a glance and invertible. Adds frozzle.test.ts.

* fix(sdk): drop lingering OAuth callback sockets on close so deny->approve re-auth works (ENG-2108)

The local OAuth callback server's close() called Server.close(), which only
stops accepting new connections and lets existing keep-alive sockets linger.
The browser / global-fetch connection pool keeps such a socket to the fixed
callback port (1456) alive. So after the user denied an MCP OAuth request and
retried, the retry's approve callback could be delivered over the pooled socket
to the FIRST (already-settled) server. That server's settle() was a no-op, so
waitForCallback() never resolved, finishAuth()/token exchange never ran, and no
token was saved — the server stayed unauthenticated (the deny->approve repro).

Call server.closeAllConnections() in close() so no pooled socket outlives the
server. Adds a regression test driving a keep-alive agent across close().

* fix(vscode): actually reconnect MCP server when toggled back on (ENG-2108)

toggleServerDisabledRPC only flipped the in-memory disabled flag and set status
to 'connecting', but never rebuilt the connection. A disabled server's
connection has no live transport/client, so re-enabling left it stuck on the
yellow 'connecting' indicator forever and never re-advertised its tools to the
agent.

Tear down and rebuild the connection through deleteConnection + connectToServer
(which opens a real transport when enabled, or a disconnected stub when
disabled), then notifyWebviewOfServerChanges so the SDK session's tool list is
refreshed. OAuth state is preserved (deleteConnection doesn't clear it). Adds
McpHub.toggleServerDisabledRPC.test.ts.

* fix(vscode): reload MCP tools silently without chat spam (ENG-2108)

Restarting the SDK session to pick up MCP tool changes appended visible chat
messages ('MCP tools changed - reloading...' and 'MCP tools reloaded
successfully...') plus a completion_result banner. Toggling several servers
piled up many of these. Tool reloading should be transparent.

Emit only the session status transitions (running -> idle) via
emitSessionEvents([], ...) instead of appendAndEmit, so no chat messages or
completion banner are shown. Genuine reload failures still surface an error
message. Updates sdk-mcp-coordinator.test.ts accordingly.

* docs(mcp): clean up comments to describe current behavior

Revise comments across the MCP OAuth and settings code to document the code as
it stands, dropping references to prior implementations, task IDs, and
before/after narration. Also reflow the auth-server regression test to the
repository's formatter. No behavior change.

* fix(vscode): atomic fallback write in remote MCP sync; document sync OAuth I/O

Make the no-McpHub branch of syncRemoteMcpServersToSettings write via an
atomic temp-file + rename so a concurrent reader never observes a torn or
empty settings file, matching every other settings write.

Document why the OAuth state read-modify-write in McpOAuthManager is
synchronous: it serializes this process's shared-file updates without a
Promise queue, which we prefer over async I/O for reliability of the
cross-process settings file.

* fix(mcp): serialize settings read-modify-writes

* docs(vscode): clarify MCP settings create race

* fix(vscode): create MCP settings atomically

* fix(cli): keep clearing missing MCP OAuth state a no-op

* fix(vscode): avoid yielding while holding MCP settings lock (#11596)

* fix(mcp): async lock acquisition for VSCode MCP settings/OAuth writes

Add updateMcpSettingsFile/updateMcpServerOAuthStateAsync to @cline/core that
yield the event loop while acquiring the cross-process settings lock instead of
blocking it with Atomics.wait. The critical section stays synchronous and the
mutator stays pure, so the lock is never held across an await and serialization
is preserved without an in-process queue.

Route the VSCode extension host's OAuth state writes (McpOAuthManager) through
the async variant so a connection-time OAuth callback can no longer freeze the
extension host event loop or deadlock against an in-flight updateMcpSettingsFile
whose lock-releasing continuation needs the loop.

Unify the sync and async acquisition paths on a shared reentrancy guard
(activeLocks) so a nested settings update on the same file fails fast instead of
self-deadlocking.

Tests: contended async serialization asserting zero Atomics.wait calls, async
stale-lock reclaim, reentrancy fail-fast, and uncontended run+release.

* fix(mcp): bootstrap missing settings file inside the lock; tidy docs

Creating the MCP settings file now happens in one place: the locked
read-modify-write helpers. A missing file reads as an empty settings object, so
the first write to a fresh path (e.g. a fresh-install `cline mcp add`) creates
it inside the lock instead of throwing ENOENT. The SDK (updateMcpSettingsFile /
updateMcpSettingsFileSync) and the VSCode lock helper share this contract, so
callers no longer need to pre-create the file. Add regression tests for the
SDK, the CLI wizard addServer(), and the VSCode helper on a missing path.

Also flag the synchronous SDK entry points (updateMcpSettingsFileSync,
updateMcpServerOAuthState) as preferring their async siblings, with a TODO to
delete them once all callers migrate, and tighten the lock-helper doc comments
to describe current behavior.

* fix(vscode): finish npm->bun migration in dev tooling, tasks, and docs

The npm->bun migration (#11632) updated package scripts, .vscodeignore and .vscode-test.mjs but left a trail of npm/npx/node invocations in editor configs, dev scripts, and docs. Following the breadcrumbs from 'npm run protos':

- .vscode/launch.json: standalone-core debug uses 'bun <file>.ts' (was npx tsx); Open Storybook uses 'bun run' (was npm run).
- .vscode/tasks.json: all task commands use 'bun run' (was npm run).
- scripts/run-extension-host.sh and .claude/hooks/claude-code-for-web-setup.sh: 'bun run' (was npm run).
- debug-harness/server.ts: shebang 'bun'; build steps use 'bun run protos', 'bun esbuild.mjs', 'bunx vite build' (were npm/node/npx).
- dev script shebangs (test-hostbridge-server, test-standalone-core-api-server, testing-platform-orchestrator, interactive-playwright): '#!/usr/bin/env bun' (was npx tsx).
- WebviewProvider HMR hint, e2e README, copilot-instructions, PR template, mcp-oauth-test-server docs, generate-state-proto message, tsconfig.test comment, state-keys test comment: bun.

Left untouched (correct per .clinerules/bun-and-node): Node-runtime invocations (node build.mjs), prebuild-install --target=<node>, vsce, 'npm install -g cline' (user CLI install), and App.stories.tsx mock chat fixtures.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-18 22:27:04 -04:00
Dominic Cooney 4c03c79b4b Migrate apps/vscode from npm/node to bun (#11632)
* chore(vscode): migrate package management & build from npm/node to bun

Fold apps/vscode (+ webview-ui, testing-platform) into the root bun
workspace so the extension consumes the local @cline/* SDK packages via
workspace symlinks instead of pinned published versions, eliminating the
SDK vendoring cycle. Node remains the runtime (extension host, standalone
cline-core, esbuild platform:node, prebuild-install ABI target).

- root: drop "!apps/vscode", add nested members, relocate overrides to
  root, add trustedDependencies [better-sqlite3, grpc-tools]
- apps/vscode: @cline/* -> workspace:*, scripts -> bun/bunx,
  npm-run-all -> bun --parallel, drop cross-env; keep esbuild + vite;
  declare previously-hoisted phantom deps (nice-grpc-common, playwright)
- package-standalone.mjs: npm install -> bun install (isolated dist dir)
- CI: setup-bun + single root bun install --frozen-lockfile, build:sdk
  before extension build, better-sqlite3 binary + zero-test guards;
  publish workflows intentionally keep setup-node for vsce/ovsx
- docs/comments: curated pass (keep-list vs rewrite-list), add
  apps/vscode/docs/bun-migration-notes.md guard doc
- delete npm lockfiles (root bun.lock authoritative)

Deferred to follow-up PRs: test-runner migration to bun test (Phase 4)
and devDep cleanup (Phase 6).

* test(vscode): add bun test foundation for the vitest-native unit suites

Phase 4a of the test-runner migration. Adds a bun test runner that
reaches full parity (582 pass / 0 fail / 50 files) with the existing
vitest SDK-adapter + model-catalog suite, without touching the
@vscode/test-cli integration tests or the webview vitest suite.

- bunfig.toml: [test] preload
- src/test/bun-test-preload.ts: mock.module() shadows `vscode` and
  `@cline/core` with their unit-test stubs (bun's onResolve plugin hook
  does not intercept host/symlinked specifiers); seeds real @cline/core
  export names as undefined to satisfy bun's strict ESM named-import
  linking; full vitest->bun:test shim (vi.fn/mocked/spyOn, describe/it/
  expect/before*/after*)
- scripts/run-bun-tests.ts: mirrors vitest.config.ts include[] exactly and
  runs with --parallel for per-file mock isolation (bun test's single-process
  default lets mock.module clobber across files)
- test:bun script

* test(vscode): migrate node-side unit suite from mocha to bun test

Phase 4b of the test-runner migration. The standalone mocha unit runner
(.mocharc spec: __tests__/* + test/services/**) was already broken under
bun (mocha was a phantom dependency — only @types/mocha/ts-node were
declared, npm hoisted mocha transitively). Migrate it to `bun test`.

- codemod 77 files: import { ... } from "mocha" -> "bun:test", renaming
  before->beforeAll / after->afterAll at imports and call-sites; chai,
  should and sinon kept as libraries (they work under bun test)
- convert sinon.stub() on ESM namespace exports to mock.module()/spyOn
  (bun loads real ESM: "ES Modules cannot be stubbed")
- scripts/run-bun-unit-tests.ts: runs the .mocharc spec set with one
  isolated `bun test` process per file (Bun.spawn + concurrency pool),
  restoring vitest-forks module-registry isolation (bun's single-process
  default lets mock.module leak across files)
- scripts/codemod-mocha-{to-bun,this}.ts: one-shot migration tooling
- test:unit now runs the bun unit runner; CI calls bun + a non-zero
  pass-count guard instead of `bunx nyc ... mocha`
- tsconfig: add root node_modules/@types to typeRoots so `bun:test`
  types resolve under tsc; cast loose os.userInfo mocks in shell.test

Result: unit suite 58 files / 880 pass / 0 fail; vitest set still
582/0. @vscode/test-cli integration tests and webview vitest unchanged.

* chore(vscode): remove dead mocha-runner deps and artifacts

Phase 6 cleanup after the bun test migration. The standalone mocha unit
runner is gone (replaced by scripts/run-bun-unit-tests.ts), so its
config and now-unused devDependencies are removed.

- remove dead files: .mocharc.json, tsconfig.unit-test.json,
  src/test/requires.ts, .nycrc.unit.json
- remove unused devDeps: @types/mocha, @types/proxyquire, ts-node,
  tsconfig-paths, cross-env, npm-run-all, nyc, proxyquire, husky
  (root owns the husky hook; chai/should/sinon stay — used as libs)
- install:all -> single root `bun install` (workspace covers webview-ui)
- drop .mocharc.json / .nycrc*.json from CI paths-filters and
  .vscodeignore; add bunfig.toml to the filters

Verified: check-types clean, unit 880/0, vitest 582/0.

* fix(vscode): import bun:test globals in tests that relied on ambient @types/mocha

CI Quality Checks (clean `bun install` without @types/mocha) surfaced
TS2582/TS2304 "Cannot find name 'describe'/'it'/'beforeEach'" in test
files that used the global mocha/jest test functions without importing
them. The Phase 4b codemod only rewrote files that imported from
"mocha"; these used ambient globals, so they were missed (and passed
locally because a stale @types/mocha lingered in node_modules).

Add explicit `bun:test` imports (before->beforeAll, after->afterAll in
TelemetryService.test.ts). chai/sinon stay as libraries.

Verified against a clean tree (no @types/mocha): check-types 0 errors,
unit suite 58 files / 880 pass / 0 fail.

* style(vscode): biome-format migrated test files + codemod scripts

The mocha->bun:test codemod and manual import edits left formatting that
didn't match biome (the CI `format` check, which validates files changed
since main, flagged them). Also narrow setup.ts's bun:test import to the
actually-used beforeEach/afterEach (describe/it only appear in a JSDoc
example), fixing a noUnusedImports lint error.

ci:check-all (check-types + lint + format) now passes locally.

* fix(webview-ui): declare phantom deps + pin React 18 types under bun workspace

Folding webview-ui into the bun workspace changed its install topology
from an isolated npm flat tree to the shared hoisted store, surfacing
two classes of pre-existing latent issues that npm hoisting had masked:

1. Phantom dependencies: src imports `marked`, `unist`, `unist-util-visit`
   and `@heroui/theme` directly but never declared them. Declared them
   (marked ^15, unist-util-visit ^5, @types/unist ^3, @heroui/theme 2.4.26).
2. React types: @testing-library/react's optional peer pulls @types/react@19
   into a resolvable location; tsc mixed it with the toolkit's React 18
   types (React 19 dropped Component.refs), breaking 452 JSX usages. Pin
   react/react-dom type resolution to webview-ui's React 18 copy via
   tsconfig paths.

build:webview (tsc -b && vite build) and ci:check-all now pass.

* fix(vscode): restore @types/mocha for integration build + add bun:test types

The @vscode/test-cli integration runner still uses mocha, and
tsconfig.test.json compiles all src/**/*.test.ts (including bun-migrated
files) to out/. So:
- restore @types/mocha (integration compile needs the mocha ambient types)
- add `bun` to tsconfig.test.json types + root @types to both tsconfig
  typeRoots so `bun:test` resolves under tsc for the migrated tests

* fix(vscode): declare glob — phantom dep used by package-standalone.mjs

scripts/package-standalone.mjs imports `glob` but it was never declared
(resolved transitively under npm's flat hoist). Under the bun workspace
store it's unresolvable, failing postcompile-standalone with
ERR_MODULE_NOT_FOUND. Declare glob ^11 (modern named-export API).

compile-standalone now produces dist-standalone/standalone.zip.

* fix(ci): strip ANSI before vitest zero-test guard grep

The vitest summary line colorizes the count ("Tests  <ansi>582 passed"),
so the count isn't adjacent to the "Tests" label in raw bytes and the
guard regex failed even though 582 tests passed. Strip ANSI escapes
before matching.

* fix(vscode): declare minimist — phantom dep in testing-platform-orchestrator

scripts/testing-platform-orchestrator.ts imports `minimist` (undeclared,
resolved transitively under npm hoist). Declare it so the testing-platform
integration job runs under the bun workspace store.

* fix(vscode): restore tsconfig-paths for integration runner; tp-orchestrator uses bun

Phase 6 over-removed tsconfig-paths: test-setup.js (loaded by the
@vscode/test-cli mocha integration runner) requires it to resolve @/
aliases in the compiled out/ tree — the extension host test runner failed
with "Cannot find module 'tsconfig-paths'". Restore it. Also switch the
testing-platform spawn from `npx ts-node index.ts` to `bun index.ts`
(bun runs TS natively; avoids the removed ts-node).

* fix(vscode): route tests by bun:test import marker; integration runner stays mocha

The mocha->bun codemod swept up tests that the Node-based @vscode/test-cli
integration runner compiles/runs, which cannot load the `bun:test` builtin
(and some need the real VSCode host). Establish a single source of truth:
a *.test.ts is bun-runner-owned IFF it imports "bun:test".

- run-bun-unit-tests.ts: discover files by the bun:test import marker
  (not fixed globs), so every migrated file runs under bun.
- build-tests.js: generate a tsconfig that excludes all bun:test files
  from the integration compile (json5-parsed), so out/ never contains
  bun:test; gitignore the generated config.
- .vscode-test.mjs: exclude the bun unit dirs from the runner globs.
- revert host-dependent tests (hostbridge/*, extension, terminal,
  FileContextTracker host bits) and 3 files with sinon-on-ESM/behavioral
  issues (ClineIgnoreController, mentions, TelemetryService) back to
  mocha; they run on @vscode/test-cli as before.

Verified: check-types 0 errors; compile-tests 0 bun:test in out/;
bun unit 65 files/962 pass/0 fail; vitest 582/0.

* fix(vscode): declare mocha — phantom dep for @vscode/test-cli integration runner

The @vscode/test-cli extension host loads `mocha` at runtime to run the
integration suite, but only @types/mocha was declared (npm hoisted the
mocha package transitively; bun's store does not expose it). The host
failed with "Cannot find module 'mocha'". Declare mocha ^11.7.4 (matches
@vscode/test-cli's own range).

* fix(vscode): robust Windows protoc-gen-ts_proto plugin resolution under bun

build-proto.mjs hardcoded node_modules/.bin/protoc-gen-ts_proto.cmd for
Windows, but bun's workspace store places/extensions the bin shim
differently (hoist + .cmd/.bunx), so Windows protos failed with
"protoc-gen-ts_proto: The system cannot find the file specified". Probe
the local + root .bin with known shim extensions instead. Also update
the testing-platform usage string (ts-node -> bun).

* fix(vscode): generate node .cmd wrapper for ts-proto plugin on Windows

The previous probe found bun's `.bunx` shim, but protoc cannot exec it
("%1 is not a valid Win32 application"). Instead, on Windows generate a
small .cmd wrapper that runs the resolved protoc-gen-ts_proto JS via
`node`, which protoc can execute regardless of package manager. POSIX
path (direct JS bin) is unchanged.

* fix(vscode): package VSIX with --no-dependencies (bundled) to stop monorepo traversal

Under the bun workspace, @cline/* are workspace:* symlinks pointing to
../../../../sdk/packages/*. vsce, walking the dependency tree, followed
them out of apps/vscode and packaged the whole monorepo (../, ~84MB incl.
root node_modules and .env), which crashed vsce's secret scanner and
failed all e2e jobs.

The extension is fully esbuild-bundled into dist/extension.js, so vsce
should not walk node_modules at all. Add --no-dependencies to every
vsce/ovsx package/publish path (e2e build, marketplace, nightly), and
tighten .vscodeignore to drop nested node_modules and dev-only inputs
(scripts, proto, testing-platform, bunfig, esbuild.mjs, etc.).

Result: VSIX is 39 files / ~7 MB and the secret scan passes.

* docs(vscode): tighten bun/node comments and consolidate into a clinerule

- add .clinerules/bun-and-node.md (eternal-now: bun=tooling, node=runtime,
  keep-list, and the bun:test-vs-mocha test routing rule); remove the
  apps/vscode/docs/bun-migration-notes.md migration doc and point
  .clinerules/general.md at the rule (single-line bullet matching the file).
- fix the hotfix-release note: there is no infra step that regenerates the
  lockfile; a CHANGELOG+version bump leaves bun.lock consistent (workspace
  versions aren't pinned) and publish runs --frozen-lockfile.
- reframe runner/preload comments to describe the code as-is (drop
  "migrated off mocha"/codemod history); add a TODO on the bun-test preload
  to migrate suites off the vitest `vi` shim to native bun:test and delete it.
- remove the one-shot mocha->bun codemod scripts.

* fix(debug-harness): pin debugee VSCode version so bundled Playwright can drive it

The harness downloaded "stable" VSCode (currently 1.125 / Electron 42),
which the bundled Playwright cannot drive — `_electron.launch()` hangs
until its 60s timeout (Electron started and a window appeared, but the
launch handshake never completed). Default to a known-good version
(1.103.0, matching the e2e CI matrix) and allow override via
VSCODE_TEST_VERSION.

* fix(webview): render under bun workspace — dedupe React, drop stale codicons link

The webview mounted but crashed before rendering (blank sidebar; e2e
"Login to Cline" never visible) with "Cannot read properties of null
(reading 'useRef')" — the classic two-React-copies / null hook dispatcher.
Under the bun workspace, sibling packages pull react@19 into the shared
store and a transitive webview dep resolved a second React instance into
the vite bundle. Add resolve.dedupe + pin react/react-dom to webview-ui's
own React 18 copy.

Also drop the separate `<link>` to node_modules/@vscode/codicons in the
webview HTML: the webview's index.css already @imports codicons, so the
font is bundled into the build assets. Under bun that node_modules path
is a symlink to the root store (outside the webview localResourceRoots)
and isn't packaged with --no-dependencies, so the link 404'd; the bundle
covers it. Re-scope the .vscodeignore nested-node_modules exclude so it
no longer shadows the codicons re-include.

* fix(debug-harness): disable GPU so the debugee renders in headless/VM envs

On headless/VM GPU stacks the debugee Electron's GPU process crash-loops
("Exiting GPU process during initialization" / CreateCommandBuffer
kTransientFailure), killing the window before Playwright finishes
attaching and tripping the 60s launch timeout. Force software rendering
(--disable-gpu and friends) for a stable harness launch.

* fix(debug-harness): survive launch failures; configurable, longer launch timeout

The harness crashed (whole bun process exited) whenever VSCode launch
failed/timed out: Playwright emits a late unhandled rejection on the dead
CDP transport after we've already handled the launch error, and the
default behavior takes the HTTP server down with it — forcing a full
restart just to retry.

- Add process-level unhandledRejection/uncaughtException guards so stray
  async errors are logged and the server keeps serving (retry via `launch`).
- On launch failure, close the orphaned Electron so a retry isn't blocked.
- Make the _electron.launch timeout configurable (--launch-timeout) and
  raise the default to 120s for cold launches; document VSCODE_TEST_VERSION.

* fix(ci): address review feedback — vsix --no-dependencies, drop stale coverage path, Windows shell

- ext-vscode-publish-stable.yml: add --no-dependencies to the release-artifact
  `vsce package` (Max's catch). Without it, vsce follows the @cline/* workspace
  symlinks out of the package and bloats the .vsix with the whole monorepo.
- ext-vscode-test.yml: drop the stale apps/vscode/coverage-unit/lcov.info upload
  path (Max's catch). That file was produced by the removed nyc unit-coverage
  step (.nycrc.unit.json); nothing generates it now.
- ext-vscode-test-e2e.yml: the better-sqlite3 assert step ran under the Windows
  runner's default pwsh and failed to parse the POSIX test. Pin it to `shell: bash`
  (Git Bash ships on windows-latest); the non-e2e job already defaults to bash.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-18 22:27:03 -04:00
Max 9c61adbd87 Improve onboarding funnel metrics (#11650)
* improve onboarding metrics

* fix onboarding page view dedupe

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:25:16 -04:00
Max Paulus 🥪 5919b0fa09 fix package lock issues post rebase 2026-06-18 22:23:02 -04:00
Max 9312f62e09 fix(vscode): persist Vertex provider settings (#11565)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:21:43 -04:00
Max b4e3cb1d2b fix(vscode): preserve legacy task metadata on resume (#11570)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-18 22:21:43 -04:00
Dominic Cooney 42163a1229 chore(vscode): remove stale HuggingFace provider test 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 7337926c6e fix standalone e2e test 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 9066e6c522 bump sdk version 2026-06-18 22:21:43 -04:00
Dominic Cooney e021e0777b fix(vscode): simpler login UX (ENG-2174) — remove inline provider gate, restore debug harness (#11534)
* remove inline no-usable-provider sign-in banner; rely on inference-time errors

The "Sign in to Cline or set up a provider" banner gated chat input on a
parallel provider-usability heuristic that mis-detected BYOK setups
(Bedrock profile/IAM, Vertex ADC) and its sign-in button discarded the
device code. Remove the component and the hasUsableProvider plumbing.

Auth/config problems now surface at inference time, where handling
already exists:
- cline provider without a token -> emitClineAuthError -> ErrorRow
  renders the Sign in button with the device-code display
- any other misconfigured provider -> say:"error" row

Also deletes the now-dead sdk/provider-usability module and adds a test
that failed session start emits a plain chat error.

* restore debug-harness server deleted in 0bfbfb944

Commit 0bfbfb944 ("delete unused files") removed src/dev/debug-harness/server.ts
as dead code, but it is a dev tool launched directly via
`npx tsx src/dev/debug-harness/server.ts` (see its README and
.clinerules/debug-harness.md) — no static import graph reaches it, which
is why the unused-file analysis flagged it. The README, the .clinerules
docs, and the CLINE_CAPTURE_BROWSER / __clineHandleUri hooks in
extension.ts and utils/env.ts that exist solely for this harness all
survived the deletion, leaving them dangling.

Restored verbatim from 0bfbfb944~1; verified it boots and listens on
:19229.
2026-06-18 22:21:43 -04:00
Saoud Rizwan c9d51f6332 fix(vscode): restart session when user switches provider (#11507)
* fix: format Cline OAuth tokens in provider config

* fix(vscode): restart SDK session on provider switch

* fix(vscode): serialize SDK provider restarts

* chore(vscode): keep provider switch PR scoped

* fix(vscode): simplify deferred provider restarts
2026-06-18 22:21:43 -04:00
Mikołaj Kondratek ffe446d55d fix: thread proxy/CA-aware fetch into the SDK inference path (#11462)
* fix: thread proxy/CA-aware fetch into the SDK inference path

The main agent loop did not receive the host's proxy/CA-aware fetch, so
on JetBrains and the CLI inference over a corporate proxy or to a
self-signed/private-CA endpoint failed with "unable to get local issuer
certificate". This regressed at the SDK cutover: the pre-SDK CLI
(2.18.0) constructed provider clients with a proxy-aware fetch directly,
while the SDK agent loop fell back to bare global fetch (CLINE-2353).

Two layers:
- App (cline-session-factory.ts): always build CoreSessionConfig.
  providerConfig and carry the proxy-aware fetch from @/shared/net, not
  just for Bedrock. In VSCode this fetch is global fetch, so behavior is
  unchanged there; in the standalone (JetBrains) build it is undici with
  EnvHttpProxyAgent.
- SDK (handler-factory.ts): forward providerConfig.fetch into
  createGateway both as the top-level fallback fetch and per provider, so
  the gateway's provider clients use it. Passing undefined is a no-op
  (registry resolves config?.fetch ?? defaults?.fetch ?? fallbackFetch),
  so other SDK consumers are unaffected.

The SDK change covers every host that supplies a fetch; the app change
covers VSCode and JetBrains. The CLI builds its session config through a
separate path (apps/cli) that does not yet wire a proxy-aware fetch, so
CLINE-2353 on the CLI surface is addressed in a follow-up.

Adds a handler-factory unit test asserting the host fetch is forwarded
to createGateway at both the top level and per provider.

* fix: deterministically install proxy dispatcher in standalone core

The proxy/CA-aware undici dispatcher is installed as a side effect of
loading @/shared/net (it calls setGlobalDispatcher with EnvHttpProxyAgent
in the standalone build). The standalone entry cline-core.ts did not
import that module, so the dispatcher was only installed incidentally
when some other transitively-imported module happened to pull it in. A
future change to the import graph could silently drop proxy/CA support on
JetBrains.

Import @/shared/net for its side effect, first, so the install is
deterministic and runs before any network use (CLINE-2353).

Standalone-only hardening; VSCode uses global fetch and is unaffected.
2026-06-18 22:21:43 -04:00
Saoud Rizwan c446ca7def fix(vscode): fix duplicate tool row when changing plan/act mode during pending tool approval (#11437)
* fix(vscode): suppress duplicate tool row when a mode change clears a pending approval

Switching plan/act while a tool approval was pending duplicated the
approval row in chat. clearPending resolved the pending approval as
denied, which unblocks the core; the core then emits the denied tool
call's content_start/content_end events before the mode coordinator's
abort lands. The interactive deny paths record the denial in the
message translator state so those events are suppressed, but
clearPending skipped that step, so the translator rendered the events
as a fresh say:tool row next to the still-visible approval ask.

clearPending now records the denial through recordDeniedToolApproval
before resolving, mirroring resolvePendingToolApproval. This covers all
clearPending callers: mode changes, task cancel, and task clear.

* refactor(vscode): trim the clearPending denial fix to its minimal shape

Keep clearPending's original structure, only inserting the denial
recording before the resolve. Drop the end-to-end suppression test:
translator suppression for recorded denials is already covered by
message-translator-approval-denial.test.ts, and the clearPending
recording is covered by the extended unit assertion.
2026-06-18 22:21:43 -04:00
Saoud Rizwan 41418bc201 fix(vscode): restore aggressive pin-to-bottom auto scroll in chat view (#11436)
* fix(webview): restore aggressive pin-to-bottom auto scroll in chat view

The auto-scroll effect only fired on groupedMessages.length changes, but in
the SDK-migrated extension new content can appear in the chat without the
message list length changing:

- The Thinking placeholder row is driven by turnState alone (e.g. the plan
  to act switch auto-continues the task with no new message), and it was
  appended to the rendered list inside MessagesArea where the scroll hook
  never saw it.
- New tool messages merge into the trailing tool group, and the thinking
  placeholder gets swapped for a real reasoning row at constant length.

Fixes:
- Lift the thinking placeholder computation out of MessagesArea into a new
  useDisplayedGroupedMessages hook so ChatView feeds the same list to both
  Virtuoso and useScrollBehavior; the placeholder appearing now pins to
  bottom like a real message.
- Key the pin effect on the tail message ts (skipping the placeholder) in
  addition to list length, covering in-place tail changes.
- Re-engage auto scroll when turnState.phase transitions into streaming. In
  the old extension every turn start was accompanied by a user send/button
  click that reset disableAutoScrollRef; turnState-driven turn starts like
  plan to act auto-continue have no webview-side action, so handle it in
  the scroll hook.

* refactor(webview): replace scroll fix with minimal single-file version

Same three behaviors as the previous commit (pin when the thinking
placeholder appears, pin on in-place tail changes, re-engage auto scroll
when a turn starts streaming) but implemented as two small effects in
MessagesArea, which already has both the rendered list and scrollBehavior
in scope. Reverts the useDisplayedGroupedMessages hook extraction and the
ChatView/useScrollBehavior changes; net diff vs the base branch is now
one file.
2026-06-18 22:21:43 -04:00
Dominic Cooney 48a4016077 test(vscode): exercise full SDK structured edit flow in file-edit e2e (#11442)
* test(vscode): exercise full SDK structured edit flow in diff.test.ts e2e (ENG-2042)

The SDK runtime executes structured (OpenAI-format) tool calls instead of parsing XML-style tool syntax out of assistant text. Teach the e2e mock server to stream an editor tool call for edit_request (arguments split across deltas to exercise fragment reassembly), answer the SDK's follow-up tool-result request (role:'tool' message) with turn-ending completion text, and remove the classic XML-era EDIT_REQUEST/REPLACE_REQUEST responses.

diff.test.ts now covers the full approval flow: approval ask row -> Save -> editor tool writes the file -> completion text, verifying the edit on disk and restoring the git-tracked fixture afterwards. The old 'test.ts: Original <-> Cline's Changes' diff-tab assertions are unreachable under the SDK executor architecture (the editor executor writes via Node fs and does not route through DiffViewProvider); this behavioral difference is documented in the test file.

* test(vscode): address review feedback on diff.test.ts e2e

- Scope the mock server's tool-result follow-up detection to edit_request conversations so tool results from other (future) scenarios don't mis-route to EDIT_REQUEST_COMPLETE.

- Move the fixture readFileSync inside the try block and guard the finally restore, so a failed read doesn't bypass cleanup attribution or write undefined back to the fixture.

* docs(vscode): rephrase diff e2e comments to describe current behavior

Comments described historical behavior (XML-style tool-call parsing that predates the SDK runtime), which is confusing to readers of the current code. Rephrase them to describe the code as it exists now.

* test(vscode): rename diff.test.ts to file-edit.test.ts and drop duplicated preamble

The test no longer touches a diff editor (the SDK editor executor writes files directly after approval), so the 'Diff Editor' name was misleading. Rename the file and describe block to match what it asserts: the file-edit approval flow.

Drop the first half of the test (send hello, wait, New Task, check history), which duplicated chat.test.ts, and the mock server's 500ms delay that existed only to support an 'API Request...' visibility assertion that no longer exists.
2026-06-18 22:21:43 -04:00
Robin Newhouse 7e2bc6488f fix(vscode): stabilize SDK e2e login flow (#11441) 2026-06-18 22:21:43 -04:00
Dominic Cooney 6ab316ad82 fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995) (#11294)
* fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995)

The VS Code skill toggle only updated extension state (globalSkillsToggles /
localSkillsToggles), but the SDK builds the model's skill list and the `skills`
tool from each SKILL.md's frontmatter `disabled` flag. As a result, disabling a
skill in the sidebar left it fully available to the model, including in new
tasks.

toggleSkill now also writes the `disabled` flag to the skill's SKILL.md
frontmatter (no-op for remote skills, which have no backing file), via new
helpers updateSkillMarkdownDisabledState / setSkillDisabledInFrontmatter in
skills.ts. Adds unit tests for both helpers.

* fix(vscode): don't rewrite skills with malformed frontmatter (ENG-1995)

parseYamlFrontmatter fails open on invalid YAML, returning the full original
document as the body. updateSkillMarkdownDisabledState would then prepend a
second `---` block on a disable, corrupting the file. Bail out and leave the
file untouched when frontmatter fails to parse. Adds tests for the malformed
disable/enable cases.

Addresses Greptile review feedback on #11294.

* test(vscode): assert malformed-skill fixture is actually invalid YAML (ENG-1995)

Add a guard test that parseYamlFrontmatter reports hadFrontmatter and a
parseError for the shared malformed fixture, so the two "leave file untouched"
tests can't silently pass via a different code path if the fixture ever became
valid YAML.

Addresses Greptile review feedback on #11294.

* fix(vscode): resolve @cline/shared/storage subpath in mocha unit-test compile

The CommonJS mocha unit-test runner uses classic "node" moduleResolution,
which does not read the `exports` subpath maps in @cline/* package
manifests, so `@cline/shared/storage` (imported by
src/sdk/telemetry-settings-sync.ts) failed with TS2307 when test files
transitively reach the SDK adapter. Mirror the explicit paths mapping
already added to tsconfig.test.json for the integration-test compile.

* fix(vscode): restore E2E mock auth in SDK auth service so e2e tests can sign in

The SDK migration replaced classic AuthService (which swapped in
AuthServiceMock under E2E_TEST) with sdk/auth-service.ts, losing the
mock path. "Login to Cline" then invoked the real SDK OAuth flow and
opened a native browser dialog the Playwright tests cannot interact
with, so helper.signin() never authenticated and chat.test.ts +
diff.test.ts failed on every platform (the failures also reproduce on
the base branch).

- auth-service.ts: under E2E_TEST=true (and CLINE_ENVIRONMENT=local),
  exchange the well-known test code with the local mock API server and
  persist credentials to providers.json — no browser. Replaces classic
  AuthServiceMock (see origin/main src/services/auth/AuthServiceMock.ts).
- chat.test.ts/diff.test.ts: wait for the mock turn to complete before
  clicking New Task; SDK history is persisted at turn end, so navigating
  mid-turn races the write and Recent never shows.
- diff.test.ts: the footer Start New Task button only appears for
  attempt_completion turns under SDK TurnState; use the header New Task
  button like chat.test.ts.
2026-06-18 22:21:43 -04:00
Saoud Rizwan 361f35841a fix(vscode): auto-continue the task when switching from plan to act (#11401)
* fix(vscode): enforce stop-before-start ordering for same-id session restarts

The app reuses the taskId as the sessionId whenever it replaces or
resumes a session (mode/MCP rebuilds, follow-up resume, history
restore), but the old session's stop ran fire-and-forget, and core
cleanup is keyed by sessionId across multiple awaits. A stop still in
flight when the same-id replacement started could tear down the live
successor: late sessions-map deletes, a late 'ended' emission, or a
stalled status write landing on the replacement.

Adopt the sequencing invariant the CLI has always used: never start a
same-id session while its stop is in flight. SdkSessionLifecycle tracks
in-flight stops in a pendingStops map keyed by sessionId, and
startNewSession awaits the pending stop for a reused id before starting
(with a log line so a wedged stop is diagnosable). Fresh-id starts
never wait. fireAndForgetSend additionally captures the ActiveSession
by object identity at send time so a send settling after a same-id
replacement cannot flip the successor's run state.

* fix(vscode): auto-continue the task when switching from plan to act

In plan mode, the model's switch_to_act_mode tool call flipped the toggle
but ended the run as aborted: the beforeModel stop hook fired after
turn-started, leaving a dangling api_req_started spinner rendered as
'API Request Cancelled', and nothing continued the task after the
act-mode rebuild. Manually toggling after a presented plan had the same
dead end.

The tool now declares lifecycle.completesRun so the run ends cleanly
after the tool result, and the queued mode change rebuilds the session
and auto-continues with a hidden continuation prompt. A manual plan to
act toggle auto-continues only when the agent is idle after presenting
its plan (not running and awaiting_followup; a pending ask_question
blocks mid-run so it cannot false-positive). Composer content rides
along: typed text becomes the continuation, attachments are forwarded
and echoed, attachment-only toggles count as consumed. The RPC reports
consumption only after the send was actually handed to the session, and
the webview then clears only the exact submitted content, so failures
and racing input never lose composer state. Failures before the send
undo the optimistic running flip, report an error phase, and roll the
mode back when the session was never replaced.

Hidden prompts (the act continuation and the pre-existing task
resumption prompt) shifted editMessageAndRegenerate's visible-to-SDK
user message ordinal mapping; the new sdk-user-message-mapping module
skips them in their persisted user_input-wrapped shape, counts
attachment-only messages (which have visible bubbles), ignores
tool-result rows, and attachment-only resumes now echo a bubble to keep
both transcripts aligned. Follow-ups sent during a rebuild wait on
waitForPendingRebuild instead of resuming a parallel session that the
rebuild would kill.

The plan-mode system prompt and tool description require explicit user
approval in a message sent after the plan was presented, preventing the
model from self-escalating to act mode.

* fix(vscode): move the turn phase to error when a task resume fails

askResponse optimistically sets the turn phase to streaming before
delegating to the followup coordinator, but the coordinator's resume
catch only posted an error row, leaving the footer stuck on
Thinking/Cancel. Resume failures (auth errors, session start errors)
now report back via onResumeFailed so the controller can set the phase
to error.
2026-06-18 22:21:43 -04:00
Saoud Rizwan e10b6e68dd fix(webview): use consistent reasoning selector component in extension provider settings (#11399)
* fix(webview): use themed components and reasoning selector in generic provider settings

The catalog-backed GenericProviderSettings path (deepseek, gemini, mistral,
and other migrated providers) rendered its model picker with raw unstyled
HTML select/input/button elements, unlike every other provider which uses
the VS Code webview-ui-toolkit components. Swap ModelPickerWithManualEntry
to VSCodeDropdown/VSCodeOption/VSCodeTextField/VSCodeButton, reusing the
DropdownContainer and re-init key workaround from common/ModelSelector.

Also render ReasoningEffortSelector in GenericProviderSettings when the
selected model's catalog info has supportsReasoning, persisting the effort
through the provider config reasoning patch, matching ClineModelPicker.
This is driven by the catalog capability flag rather than provider id.

* fix(webview): re-sync custom model id field after async config hydration

The controlled customModelId state was initialized once at mount, but the
provider config and model catalog both hydrate asynchronously, so the lazy
initializer could capture a placeholder value and leave the custom model
text field stale once the committed selection loaded. Sync the field via an
effect keyed on the committed model id and its in-list status, depending on
derived values rather than the models object whose identity can change
every render while the catalog loads.
2026-06-18 22:21:43 -04:00
Robin Newhouse 713631e1ea fix(vscode): expand remote workflow/skill slash commands before send ENG-2036 (#11388)
* fix(vscode): expand remote workflow/skill slash commands before send

The SDK-backed extension sent `/workflow` text to the model verbatim, so
remote-config workflows never ran. Expansion is host-driven (the agent loop
never auto-expands), and the controller's pre-send path did none — matching
the CLI's `buildUserInputMessage`, resolve slash commands via a
controller-owned UserInstructionConfigService that watches the workspace
(including `.cline/remote-config/`), refreshed after each remote-config sync.

Fixes ENG-2036.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vscode): guard instruction watcher against post-dispose race

Reject in ensureUserInstructionService when the controller is already
disposed so a slash-command resolution that yielded across dispose() can't
resurrect a file watcher that nothing will stop. Also log the post-expansion
length handed to parseMentions. Addresses Greptile review.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 22:21:43 -04:00
Max Paulus 🥪 0194c98b0f include optional deps so that CI passes 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 cbd73d2c1f fix broken tests 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 25fd83ac4e bump sdk version 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 86c61b96f6 add vertex support to extension 2026-06-18 22:21:43 -04:00
Mikołaj Kondratek 0cba47c145 fix(sdk): make model-not-found API errors actionable in the webview (#11378)
When a provider returns a model-not-found error (e.g. Anthropic's HTTP 404
for a retired model such as claude-3-haiku-20240307), the SDK strips the
status and delivers only the terse body, which collapses to the bare label
"model: <id>". reshapeErrorForWebview fell through to returning that raw
string, so ErrorRow rendered a label-like fragment in red with no hint that
the model is gone or how to recover.

Detect these in the plain-text branch of reshapeErrorForWebview and rewrite
them into a sentence that names the model and tells the user to switch models
in API Configuration settings, then retry. The model switch is framed as a
precondition rather than a parallel option so users don't loop on Retry.

Detection is text-based because the HTTP status is unavailable at this point.
The keyword match is anchored to the word "model" with a not-found signal in
the same sentence, so unrelated errors that merely mention a model (plan
gating, deprecated features) are left untouched. Adds tests for the bare
label form, a generic "does not exist" form, and two negative cases (plan
gating and an auth error mentioning a model) that must pass through unchanged.
2026-06-18 22:21:43 -04:00
Max Paulus 🥪 ead0f9373a fix telemtry opt flag migration 2026-06-18 22:21:43 -04:00
Dominic Cooney 1ff2fe7887 fix(vscode): resolve @cline/shared/storage subpath in test compile + vitest
The CommonJS integration-test tsconfig (moduleResolution: node) and the
vitest config did not resolve the @cline/shared/storage exports subpath
imported by src/sdk/SdkController.ts, breaking 'compile-tests' (TS2307)
and 3 vitest SDK suites. Add explicit path/alias mappings to the built
dist so both resolve without changing module emit. Compile-time/test-only;
emitted JS still uses the real package specifier.
2026-06-18 22:21:43 -04:00
Max Paulus 🥪 6b2c9213d0 migrate telemetry value in extension 2026-06-18 22:21:43 -04:00
Max Paulus 🥪 5ee67a7f20 bump sdk version 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 2a9057f184 fix claude-code setting loading/persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 ae3234212c fix task history delete 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 24c489eceb fix model selector not showing most up to date model in providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 b3ccece14e fix ui test 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 7c4cf02657 fix ci checks 2026-06-18 22:21:42 -04:00
Robin Newhouse 2c6de4c6a6 refactor(vscode): remove MCP marketplace ENG-1591 (#11217)
* refactor(vscode): remove MCP marketplace

* test(vscode): clarify MCP marketplace removal test

* docs: update MCP server controls docs
2026-06-18 22:21:42 -04:00
Max Paulus 🥪 4d72d4e6f4 fix anthropic provider settings persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 82c8f28dae remove baseUrl from providers.json when unchecking box in ui 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 b31bed5b60 fix ollama and lmtudio settings persistence 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 ec69289e40 fix openrouter apikey persist to providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 fed81c55b1 fix vscodelm provider settings persist 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 2fc24d30de persist bedrock settings to providers.json 2026-06-18 22:21:42 -04:00
Max Paulus 🥪 178d3db1ab don't block user input when hasNoUsableProvider == true 2026-06-18 22:21:42 -04:00
Mikołaj Kondratek 685103b193 fix(bedrock): treat profile/IAM/credential-chain auth as a usable provider (#11313)
The inline "Sign in to Cline or add an API key" gate appeared and disabled
chat for Amazon Bedrock users who configured AWS Credentials (access key +
secret), an AWS profile, or relied on the default AWS credential chain, even
though the provider was fully usable (issue #11270).

hasUsableProvider() decided Bedrock usability solely via resolveApiKey(),
which maps bedrock -> awsBedrockApiKey. Bedrock's three non-API-key auth
modes leave that field empty, so buildBedrockProviderConfig() would build a
working session while the gate reported the provider unusable. The Cline
login state is irrelevant here: the gate is computed for the active-mode
provider, and the "Sign in to Cline" button is just one of two generic
remedies, which is what made the symptom look like a logged-out state.

Add a Bedrock branch that classifies usability per auth mode, reusing
resolveBedrockAuthentication() so the gate and the session builder agree on
what each mode means:
- api-key: usable only when awsBedrockApiKey is non-blank (unchanged, now
  also rejects whitespace-only keys)
- profile / iam / default credential chain: usable, deferring credential
  resolution to request time (mirrors buildBedrockProviderConfig and the
  existing keyless-provider philosophy)

Manually verified on a real setup across all four auth modes: pre-fix the
gate blocked chat for access-key and profile auth; post-fix the gate clears
and chat works. API-key mode was never gated incorrectly.

Tests: add Bedrock coverage for every auth mode, including api-key with a
blank and with an unset key (both not usable), the SigV4 repro, profile
(explicit/inferred/awsUseProfile), the bare credential-chain config, and
plan-mode resolution plus plan/act isolation.
2026-06-18 22:21:42 -04:00
Ara 015ece10b6 Fix SDK task size in delete tooltip (#11277)
* fix: show SDK task size in delete tooltip

* fix: address SDK task size review feedback

* fix: simplify SDK task size caching
2026-06-18 22:21:42 -04:00
Max Paulus 🥪 155c38f7c8 remove unused code/files
fix broken webview build

remove unused code/files

fix broken webview build
2026-06-18 22:21:42 -04:00
Max Paulus 🥪 4619bb78bc delete unused files 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 7799128c42 Add edit and regenerate for VS Code chat messages
Allow user feedback messages in the VS Code extension to be edited inline and regenerated from that point. Adds a TaskService RPC, truncates persisted SDK history before the selected visible user prompt, and starts a new session with the edited prompt. Also ensures the regenerated active task appears in extension history while SDK history catches up.
2026-06-18 22:16:51 -04:00
Max Paulus 🥪 851d3857c2 fix webview-ui tests 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 9536a39cb7 show model list if possible for openai compatible 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 63a78519a3 fix onboarding model selection not persisting 2026-06-18 22:16:51 -04:00
Max Paulus 🥪 91b1ecd354 remove provider-specific views and just use genericprovidersettings.tsx 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 1e6c2c0286 dry up duplicate code and create useProviderModelSelection 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 571f2fbde8 dry up provider api key logic 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 de2094f27f dry up some duplicate code 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 8f43de8c2a fix onboarding models 2026-06-18 22:11:53 -04:00
Max Paulus 🥪 16a3895134 fix failing biome/lint 2026-06-18 22:10:34 -04:00
Mikołaj Kondratek 1deb0f4c52 Remove unused import 2026-06-18 22:09:08 -04:00
Mikołaj Kondratek 5b71d48b31 fix(sdk): drop dead autoContinue branch in mode rebuild
cf25cd66a ("make extension plan mode more similar to CLI") removed the
file-level ACT_MODE_CONTINUATION_PROMPT constant and stopped passing the
autoContinue / continuationPrompt options when rebuilding a session for
a mode change, but left the corresponding block inside
rebuildSessionForMode in place. The block still references the deleted
constant, so tsc fails on the SDK migration branch with TS2304: Cannot
find name "ACT_MODE_CONTINUATION_PROMPT".

No caller passes options to rebuildSessionForMode anymore, so the block
is dead. Drop the block and narrow the signature to take only newMode.
Existing tests already invoke rebuildSessionForMode(<mode>) with no
second argument and assert that fireAndForgetSend is not called on a
mode rebuild, so they keep passing.
2026-06-18 22:09:08 -04:00
Mikołaj Kondratek a37cb01c8a fix(terminal): capture standalone terminal output on Windows and harden PowerShell command handling (#11133)
* fix(terminal): surface standalone terminal spawn diagnostics

Add Logger calls at every chokepoint of the standalone terminal pipeline
so the (currently silent) failure modes around JetBrains-hosted
cline-core become debuggable from cline-core-service.log.

Lines added, all using the existing Logger facility (no new
dependencies, no behavioral changes):

* StandaloneTerminalProcess.run() now logs:
  - `[StandaloneTerminalProcess] run() entered: shell=… cwd=… args=…`
    on entry, before the try block;
  - `[StandaloneTerminalProcess] spawned pid=… for shell=…` right
    after child_process.spawn returns;
  - `[StandaloneTerminalProcess] close: code=… signal=… fullOutputLen=…`
    inside the `close` handler (the `fullOutputLen` reveals when the
    child exits 0 with empty pipes — the symptom in issue #10948);
  - `[StandaloneTerminalProcess] child error: …` in the `error`
    handler;
  - `[StandaloneTerminalProcess] spawn threw synchronously: …` in
    the outer catch.

* StandaloneTerminalManager.runCommand() now logs entry
  (`[StandaloneTerminalManager] runCommand terminalId=…: <cmd>`) and
  attaches a `.catch` to the previously fire-and-forget
  `process.run(…)` Promise so an unhandled rejection surfaces as
  `[StandaloneTerminalManager] process.run rejected for terminal …`
  instead of disappearing.

* CommandExecutor.execute() extends the existing "Executing command
  in … terminal" line with `mode=<terminalExecutionMode>` and
  `managerCtor=<manager.constructor.name>`, so it's possible to
  confirm whether the `vscodeTerminal` path is in fact backed by a
  `StandaloneTerminalManager` on JetBrains (it is — see
  notes/issue-10948-…md).

* CommandOrchestrator.orchestrateCommandExecution() logs the
  `process.once("completed")` event with `exitCode`/`signal`/
  `terminalType`, the "resolved completed" return branch with the
  line/byte totals, and emits a `WARN` on the silent "still running"
  fall-through. The last one matters because the original repro
  reported "Command executed successfully (exit code 0)" with empty
  output — the WARN makes that branch loud the next time it fires.

These logs are what made the two distinct bugs in #10948 visible
(see the 2026-05-28 update in
notes/issue-10948-terminal-output-investigation-2026-05-27.md). They
stay in to keep the next regression debuggable.

Refs: cline/cline#10948

* fix(terminal): keep Windows child stdio attached to parent pipes

The non-cmd Windows branch in StandaloneTerminalProcess.run() spawned
the shell (powershell.exe in practice) with `detached: true` and no
`windowsHide`. When cline-core is launched by the JetBrains plugin it
has no console of its own, so Windows CreateProcess allocates a NEW
console for the detached child and the child's stdio routes to that
new console instead of the pipe handles the parent created. From the
parent's point of view the pipes immediately EOF, `close` fires with
`code=0`, and `fullOutput` is 0 bytes — exactly the symptom reported
in cline/cline#10948 ("Command executed successfully (exit code 0)"
with no output and no filesystem effect).

This bug applies to every command the agent runs through the
standalone terminal path on Windows, not just the
double-wrapped-PowerShell cases (verified by re-running a clean
`dir <file>` after the diagnostics from the previous commit landed:
`run() entered` and `spawned pid=<num>` both fired, then `close: code=0
fullOutputLen=0`).

Fix:

* `detached: process.platform !== "win32"` — keep the existing
  POSIX behavior (a separate process group helps `tree-kill`), but
  drop it on Windows where `tree-kill` walks the PID tree with
  `taskkill /T` and doesn't need a process group.
* `windowsHide: true` — matches every other `child_process.spawn`
  call site in cline-core (git, MCP, hooks, browser) and flips on
  `CREATE_NO_WINDOW`, keeping the child attached to our pipes
  without popping a console window.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal: `dir <path>`-style probes now produce a non-zero
`fullOutputLen` in the close log, and the captured output bytes
match what would have been visible interactively. PowerShell
double-wrapping (the other half of #10948) is handled in a
follow-up commit.

Refs: cline/cline#10948

* fix(terminal): harden PowerShell command wrapping for standalone shell

`StandaloneTerminalProcess.getShellArgs()` blindly wrapped every
PowerShell command as `["-Command", command]`. That has two
end-user-visible failure modes on Windows, both observed in
cline/cline#10948:

1. The agent's `run_commands` tool call sometimes arrives already
   prefixed with `powershell -Command "…"`. We then spawned
   `powershell.exe -Command 'powershell -Command "…"'`, and the
   outer shell shredded the inner single/double-quote pairs while
   re-parsing its `-Command` argument. The inner pwsh saw
   quote-empty `Test-Path` calls, fell through to the `else` branch
   and reported "File not found" — to ITS stdout, which the outer
   inherited but the file deletion the LLM intended never ran.
2. The user's `$PROFILE` script ran on every spawn, leaking
   non-deterministic noise (e.g.
   `%windir%\System32\REG.exe : The module '%windir%' could not be
   loaded`) into the captured output and confusing the agent.
3. Bonus: the POSIX branch used `["-l", "-c", command]`. The `-l`
   re-sources login files on every command, which is slow and lets
   greeter scripts leak into output.
4. Bonus: the cmd branch used `["/c", command]`. `/d` skips
   AutoRun, `/s` makes the embedded-quote handling deterministic.

Fix:

* PowerShell branch returns
  `["-NoProfile", "-NonInteractive", "-Command", unwrap(command)]`.
  `-NoProfile` suppresses (1) the spurious profile noise that
  contaminated the captured output, and `-NonInteractive` ensures
  the child doesn't deadlock waiting on a prompt no one will answer.
* `unwrapPowerShell(command)` strips a leading
  `powershell|pwsh [.exe] -Command|-c "…"` (or single-quoted)
  wrapper that the LLM sometimes emits, fixing the double-pass
  argument-quoting destruction. If the command does not match the
  exact wrapper shape it is returned verbatim — worst case is "no
  change", preserving pre-fix behavior.
* cmd branch returns `["/d", "/s", "/c", command]`, matching the
  canonical helper in cline/sdk/packages/shared/src/parse/shell.ts.
* POSIX branch returns `["-c", command]`, dropping the unhelpful
  `-l`. Also matches the SDK helper.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal in combination with the previous "keep Windows
child stdio attached" commit: `Remove-Item CHANGELOG.md` now
deletes the file, the agent's verification `Get-ChildItem CHANGELOG*`
returns nothing, and the profile-load REG.exe error no longer leaks
into captured output.

Refs: cline/cline#10948

* refactor(terminal): tone down standalone terminal diagnostics

The diagnostics added while chasing #10948 were intentionally loud so the
two bugs were visible. Now that the fixes are in, reduce them to a normal
operating posture:

* Demote fine-grained traces to `debug`: the per-spawn `spawning …` and
  `spawned pid=…` lines, `StandaloneTerminalManager.runCommand`, and the
  orchestrator's `resolved completed` summary.
* Drop the orchestrator's `completed event` line entirely — the
  `resolved completed` debug line already carries exit code, signal, and
  line/byte totals.
* Stop echoing the full command in the manager line and stop echoing the
  args vector in the spawn line. The command is still logged once at
  `info` by CommandExecutor (unchanged, pre-existing), so we go back from
  three command echoes to one. Commands routinely embed secrets
  (Authorization headers, tokens), so fewer copies on disk is better.

Kept loud on purpose:

* `info` on `close: code=… fullOutputLen=…` — the single line that proves
  the Windows stdio-capture fix and the most useful per-command signal.
* `warn` on `resolved without completion event` — the silent-success
  canary for the #10948 failure mode.
* `error` on child error / synchronous spawn failure / unhandled
  process.run rejection.

Refs: cline/cline#10948

* fix(terminal): tighten PowerShell unwrap regex and extract to a pure module

Two review follow-ups for the #10948 shell-arg handling:

1. The wrapper-strip regex used a greedy `([\s\S]*)` body, so a command
   like `powershell -Command "foo" "bar"` would match with the body
   captured as `foo" "bar`, silently rewriting a command into something
   different. Replace the body with a tempered match `((?:(?!\1).)*)`
   that cannot contain the captured delimiter, so anything other than
   exactly one quoted token is returned verbatim. Worst case is now
   "no change" rather than an incorrect rewrite. The legitimate
   double-wrapped case from #10948 (outer ", inner ') still unwraps.

2. `getShellArgs` and `unwrapPowerShell` were private methods on
   StandaloneTerminalProcess, untestable without spawning a process.
   Move them to a pure `shellArgs.ts` module. `getShellArgs` now takes
   an injectable `platform` (defaulting to `process.platform`) purely so
   the win32-vs-posix branch is testable; behavior is unchanged. This
   also gives us a single local seam to later consolidate onto the
   canonical `@cline/shared` helper (tracked as a follow-up).

No behavioral change beyond the regex correctness fix.

Refs: cline/cline#10948

* test(terminal): cover shell-arg construction and PowerShell unwrap

Add mocha unit tests (matching the repo's node:assert/strict + __tests__/
convention so the existing mocharc spec globs pick them up) for the newly
extracted shellArgs module:

* unwrapPowerShell: double-quote and single-quote wrappers, powershell.exe
  -c form, the #10948 nested-quote repro (inner quotes preserved),
  non-wrapped passthrough, and the two regressions the tightened regex
  must reject (`… "foo" "bar"` and a command that merely mentions
  powershell mid-string).
* getShellArgs: PowerShell -> -NoProfile -NonInteractive -Command (with
  unwrap), cmd -> /d /s /c, POSIX -> -c. The injectable platform arg lets
  these run on any CI host.

This closes the M1 review finding (the regex was the riskiest line in the
change and had zero coverage) and exercises the cmd/POSIX flag changes
called out in M2.

Refs: cline/cline#10948

* docs(terminal): drop issue references and clarify windowsHide comment

Remove inline issue-number references from source comments and a test
name; that context belongs in the commit history, not the code. Also add
a one-line note that windowsHide is a no-op on non-Windows platforms,
since it is set unconditionally while the surrounding comment is
Windows-specific.

No behavior change.

* refactor(terminal): drop warn on the non-completion return path

The orchestrator's final fall-through return is a normal, expected path:
the process resolved via `continue` without a `completed` event (e.g. a
terminal mode without shell integration, or proceed-while-running flows).
Logging it at `warn` cries wolf on healthy runs, so remove it. The
genuine failure mode this was meant to catch surfaces through the
`close`/error logs and the result string itself.

* fix(terminal): address review feedback on standalone spawn paths

Three follow-ups from code review:

* StandaloneTerminalManager.runCommand: the unawaited process.run()
  .catch only logged. run() emits "error" for failures it catches, but a
  rejection escaping without an "error" event would leave the outer
  promise (resolved via the "continue"/"error" events) pending forever,
  stalling the caller. Re-emit "error" from the catch so both paths stay
  consistent. Cannot trigger today (no await outside run()'s try/catch)
  but the guard exists precisely for future rejections.

* shellArgs POSIX branch: document that dropping the login flag (`-l`)
  is intentional and relies on the child inheriting the parent's PATH via
  process.env, with a note that a GUI-launched IDE without a login PATH
  is the edge case to watch.

* StandaloneTerminalProcess cmd.exe branch: add windowsHide:true. The
  console-allocation/window-pop problem is not exclusive to the non-cmd
  branch; a console-less parent could pop a window for cmd.exe too.
  No-op on non-Windows.
2026-06-18 22:09:08 -04:00
Ara aa7b06f5c9 Remove Explain Changes feature (#11278)
* chore(vscode): remove explain changes entry points

* chore(vscode): remove explain changes feature
2026-06-18 22:09:08 -04:00
Max Paulus 🥪 2310ce56ae make extension plan mode more similar to CLI
- basically, don't auto continue when agent switches to act mode
2026-06-18 22:09:08 -04:00
Max Paulus 🥪 69976c2a23 fix zai insufficient credits issue 2026-06-18 22:09:08 -04:00
Max Paulus 🥪 f8d3220c86 fix tool use name sanitization 2026-06-18 22:08:09 -04:00
Max Paulus 🥪 161682d858 fix broken tsc 2026-06-18 22:08:09 -04:00
Dominic Cooney a7f4d4778b fix(vscode): exclude vitest src/sdk suites from CommonJS test compile
compile-tests runs 'tsc -p tsconfig.test.json' (module: commonjs) over all
src/**/*.test.ts for the VS Code integration runner. The new src/sdk vitest
suites use top-level 'await import(...)' (after vi.mock), which is invalid
under CommonJS and fails with TS1378. The integration runner never runs
src/sdk anyway (.vscode-test.mjs only globs core/test/utils/shared/
integrations/hosts/services); these run via 'npm run test:vitest'. Exclude
src/sdk/**/*.test.ts from the integration compile.
2026-06-18 22:08:09 -04:00
Dominic Cooney 2f00a50fed fix(vscode): restore biome --config-path so lint resolves apps/vscode/biome.jsonc
The rebase dropped '--config-path ./biome.jsonc' from the lint/format/
postprotos scripts and removed the '!!**/.vscode-test' ignore from
biome.jsonc. Without the explicit config path, biome auto-discovered the
root biome.json instead of apps/vscode/biome.jsonc, applying the wrong
rule severities (449 errors at error level for rules that are off/info in
the nested config). Restore both to match origin/main and apply the
pending buf format fix to models.proto.
2026-06-18 22:08:09 -04:00
Dominic Cooney 6313920e71 fix(vscode): show running state for in-progress commands
The command row reflects an executing state while a command runs. The
message translator includes the command-output marker on the running
command row so the webview renders it as executing; the row is finalized
with output and a completed flag when the command ends.

Also remove the unused onChange parameter from the foreground run_commands
path: the SDK runtime does not pass it, so it had no effect. Foreground
command output is surfaced to the chat at completion, not incrementally.

Fixes CLINE-2298 and CLINE-2162
2026-06-18 22:08:09 -04:00
Dominic Cooney f2eb188eed fix(vscode): re-enable approval buttons for consecutive asks
The footer Approve/Reject buttons stayed disabled when a second consecutive
approval ask arrived. The button configs are shared singletons (e.g.
BUTTON_CONFIGS.tool_approve), so two identical asks return the same object
reference and the effect that reset the processing latch never re-ran.

Key the processing latch on the ask identity (anchored turn timestamp plus the
button labels) rather than the config object reference, using a ref-based latch
so each new ask re-enables the buttons. Adds a regression test.

Test plan:

1. Ask the agent to generate two requests to ls /tmp at once

2. Approve (or reject) the first request

3. Check that the buttons for the second request are enabled
2026-06-18 22:08:09 -04:00
Dominic Cooney f8c81f1148 feat(vscode): add the VS Code Language Model (vscode-lm) provider
Run Cline inference through the VS Code Language Model API (vscode.lm), enabling
models contributed by any extension that registers a language model chat
provider with VS Code. GitHub Copilot is the most common such vendor, but the
implementation is vendor-agnostic — it selects models via
vscode.lm.selectChatModels and has no Copilot-specific logic.

- VsCodeLmHandler implements the Cline SDK ApiHandler and is registered with the
  SDK handler registry; the model selector travels as a vendor/family[/version/id]
  string in modelId and is parsed back here. Selector segments are
  percent-encoded so values containing slashes round-trip intact.
- Native tool calling: tool definitions are passed to sendRequest and tool calls
  are surfaced as tool-call chunks; tool results round-trip as
  LanguageModelToolResultPart, with structured tool output serialized to text and
  a trailing user message appended when a turn ends on tool results so models can
  read the output.
- Gated to VS Code: registration is conditioned on the vscode.lm API being
  present, and the provider is hidden in the UI on hosts without it (JetBrains).

Depends on @cline/{shared,llms,agents,core} 0.0.42-nightly.1780514867, the first
published SDK build with the custom-registered-handler routing this provider
needs.
2026-06-18 22:08:09 -04:00
Ara 5360a2af4f Fix approval chat replies rendering as tool errors (#11246)
* fix(vscode): route approval chat replies as user feedback

* fix(vscode): suppress approval reply denial errors

* fix(vscode): hide rejected approval tool failures

* chore(vscode): clarify denied approval suppression helper
2026-06-18 22:08:09 -04:00
Max Paulus 🥪 963fac8f6d Persist OpenRouter provider config via catalog hook 2026-06-18 22:08:09 -04:00
Max Paulus 🥪 a89795fefb persist openai-codex provider model settings
- also don't show a sign in button if openai-codex is the only provider
2026-06-18 22:08:09 -04:00
Max Paulus 🥪 7addf92aea Persist Cline model selections to provider config 2026-06-18 22:08:09 -04:00
Dominic Cooney 99765931db fix(vscode): declare missing direct deps @grpc/proto-loader and @opentelemetry/api-logs
Both packages are imported directly from source but were never declared in
apps/vscode/package.json, so they only resolved transitively. On a clean
install this broke:

- @grpc/proto-loader — imported by scripts/proto-utils.mjs,
  src/standalone/utils.ts and src/standalone/hostbridge-client.ts; its absence
  made `npm run protos` (and therefore the whole build) fail on a fresh checkout.
- @opentelemetry/api-logs — imported by the OpenTelemetry telemetry providers;
  its absence produced TS2307 "Cannot find module" errors under tsc.

Versions are pinned to align with the existing dependency families already
declared in this package (@grpc/grpc-js ^1.9.x → proto-loader ^0.7.13;
the @opentelemetry/* 0.56.x line → api-logs ^0.56.0). The npm and bun
lockfiles are updated accordingly (the api-logs change also dedupes several
previously-nested copies to a single hoisted entry).
2026-06-18 22:08:09 -04:00
Max Paulus 🥪 00672f2fd0 show legacy task history that is not saved in the ~/.cline folder 2026-06-18 22:06:01 -04:00
Max Paulus 🥪 efc5d3ade5 add migration telemetry 2026-06-18 22:06:01 -04:00
Ara a1b596c3ef fix(vscode): reuse approved tool rows (#11213)
* fix(vscode): reuse approved tool rows

* fix(vscode): clear stale approved tool rows
2026-06-18 22:06:01 -04:00
Dominic Cooney 7e917746a0 sdk migration: squashed pre-2026-06-02 work
Omnibus squash of the 10 oldest SDK-migration commits (authored 2026-05-27
through 2026-06-02), collapsed during the 2026-06-09 rebase onto origin/main.

Squashed commits:
- sdk migration: squashed pre-2026-05-27 work
- sdk migration: squashed 06-05-2026 -- instead of listHistory, use host.get(sessionId) instead
- updat gitignore
- fix xai provider
- fix(vscode): forward Bedrock region + AWS auth to the SDK gateway
- fix(vscode): keep in-progress MCP OAuth flow across reconnects
- fix(vscode): wire auto compact into SDK sessions (#11197)
- fix(vscode): compact Codex OAuth before input cap (#11194)
- fix unauthed user flow
- fix(llms): strip Cerebras reasoning history (#11214)
2026-06-18 22:06:01 -04:00
2326 changed files with 52963 additions and 526747 deletions
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-extension
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/tuistory
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-extension
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/tuistory
+1 -1
View File
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
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.
-177
View File
@@ -1,177 +0,0 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## Release contract
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
0. Ask which channel this release is for — **stable or beta** — if the user has not said. Everything below branches on it; never guess.
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
For a **beta** release, work on `desktop-experimental` (check out `origin/desktop-experimental`; merge `origin/main` into it first if it is behind — see EXPERIMENTAL.md for the conflict policy) and read the version files from that branch. The last-tag baseline is the newest `desktop-v*` tag of either channel that is an ancestor of the branch.
2. Collect release commits.
```sh
# stable (on main):
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
# beta (on desktop-experimental):
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Stable: ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
Beta: apply the versioning rule — base = next stable version, increment `N` (`0.0.14-beta.1``0.0.14-beta.2`; after stable `0.0.14` ships, next is `0.0.15-beta.1`). Confirm the computed version with the user.
5. Update release files (on `main` for stable, on `desktop-experimental` for beta).
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date; `## X.Y.Z-beta.N` for beta) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z" # beta: desktop-vX.Y.Z-beta.N / "Desktop vX.Y.Z-beta.N"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on the channel's branch (`main` for stable, `desktop-experimental` for beta) and the tag pushed first. Dispatch from `main` for **both** channels (see the release contract for why).
```sh
# stable:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z -f channel=stable -f confirm_publish=publish
# beta:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z-beta.N -f channel=beta -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
**The run pauses for approval.** `validate` runs immediately, then the `build`
job waits on the `PublishDesktop` environment until a required reviewer approves
it — the run sits in `waiting`, which is expected, not a hang. Approve it in the
run's web UI ("Review deployments"), or:
```sh
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
--method POST -f state=approved -f comment="desktop vX.Y.Z" \
-F 'environment_ids[]=19152605990' # PublishDesktop
```
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30 # stable
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
```
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
10. Final response.
Report: channel, version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Publish secrets (one-time setup)
These live on the **`PublishDesktop` environment**, not at repository level, so
only the `build` job can read them and only after an approval. Set them under
Settings → Environments → PublishDesktop → Environment secrets. The environment
also restricts deployments to `main` and requires a reviewer.
Adding one of these as a *repository* secret is the common mistake. The build
would still succeed — an environment-gated job resolves repository secrets too,
with environment values simply taking precedence — so the credential would sit
repo-wide while everything looked fine. `validate` therefore fails the run if any
of them resolves in a job with no environment. If you hit that, delete the
repository-level copy rather than duplicating it.
If a secret is missing everywhere, the preflight in `build` fails the run naming
the missing entries. The Apple values come from the same Apple Developer account
used for manual signing (see the app README's "macOS signing & notarization"
section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
`ERROR_SERVICE_API_KEY`, OTEL settings) are shared with the CLI, SDK, and extension
publish workflows and already configured. **Do not move these into
`PublishDesktop`** — scoping them to this environment empties them in every other
publish workflow, silently, with no error beyond missing telemetry and a failed
Slack post.
-186
View File
@@ -1,186 +0,0 @@
---
name: publish-extension
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
---
# VS Code Extension Release
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
## The current era: combined A/B rollout
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
### The listings and the workflows
| Channel | Marketplace ID | Workflow | Trigger | Version |
|---|---|---|---|---|
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish``Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
## Golden rules (read before any release)
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
```bash
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
```
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
```bash
node -e '
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
(async () => {
let t = 0, n = 200;
for (let i = 0; i < n; i += 20) {
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
}).then(r => r.json())));
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
}
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
})()' "$KEY"
```
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
## Stable release (combined A/B VSIX) — the current stable path
### Pre-flight
```bash
# 1. What's live, and what version comes next (must exceed it — rule 1)
# 2. Flag percentage (rule 2) — decide where it should be for this release
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
git fetch origin main legacy-extension
git log --oneline -3 origin/legacy-extension
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
# hard-fails if views/viewsContainers/configuration diverged between branches.
git show origin/main:apps/vscode/package.json > /tmp/next.json
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
```
Release prep on `main` (PR, not direct push):
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
### Dispatch
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f publish=true
# (the legacy bundle always builds from the protected legacy-extension branch;
# it is deliberately not an input)
# publish=false builds an installable .vsix artifact without publishing and
# needs NO environment approval — the ungated build job uploads the artifact
# and the run completes.
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
```
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
```bash
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
```
### Post-publish
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
```bash
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
```
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
```
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
### Known caveats of this path
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
- A red run can still mean a successful publish on paths that tag (see Gotchas).
## Nightly release
Happens automatically (cron 12:00 UTC). Manual cut:
```bash
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
gh run watch <run-id> --exit-status --interval 60
```
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
## Legacy hotfix release (and emergency full rollback)
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
```bash
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
# highest version ever published to the listing (rule 1 — including combined
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release
# (the branch is hardcoded to legacy-extension in the workflow; it is
# deliberately not an input)
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
## Cutover: retiring the A/B machinery (the endgame)
When the next bundle has held at 100% long enough to trust:
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
6. Update this skill: delete the combined-era sections and keep the standalone flow.
## Gotchas index
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
-158
View File
@@ -1,158 +0,0 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -1,4 +0,0 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
-107
View File
@@ -1,107 +0,0 @@
---
name: tuistory
description: |
Drive and test terminal apps (especially the Cline CLI TUI in apps/cli) through tuistory — named background PTY sessions that agents can read, wait on, snapshot, screenshot, and type into. Like Playwright/tmux for terminals, with reactive waiting instead of blind `sleep`.
Use this skill when you need to:
- Manually test or reproduce bugs in the interactive Cline TUI (`bun run cli -i`) from a headless environment
- Run a dev server or any long-lived/interactive process in the background without hanging your tool call
- Write or extend Playwright-style e2e tests for the TUI (`bun run test:e2e:tuistory` in apps/cli)
- Capture text snapshots or styled PNG screenshots of a TUI screen as evidence
---
# tuistory
[tuistory](https://github.com/remorses/tuistory) wraps any terminal command in a named background PTY session backed by a Ghostty terminal emulator. Agents interact with the session via short CLI calls that return instantly; humans can `tuistory attach` to the same session to watch or intervene. No real terminal or display (`DISPLAY`) is needed — it works fully headless, which makes it the preferred way for cloud agents to exercise the Cline TUI.
It is installed as a devDependency of `@cline/cli`, so the pinned binary resolves when you run from `apps/cli`:
```bash
cd apps/cli
bunx tuistory --help # source of truth for commands, options, and syntax
```
For full upstream docs: `curl -s https://raw.githubusercontent.com/remorses/tuistory/refs/heads/main/README.md`
## Driving the Cline TUI headlessly
Launch the TUI in an isolated environment so you don't touch real user config (`~/.cline`):
```bash
cd apps/cli
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
bunx tuistory -s cline --cols 120 --rows 36 \
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
```
The dummy `-k test-key` renders the full chat UI; only an actual agent turn would fail. For recorded LLM turns, use the VCR cassettes described in `apps/cli/src/tests/helpers/env.ts` (`CLINE_VCR=playback` + `CLINE_VCR_CASSETTE`). Real turns need a provider credential (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`).
Then use an **observe → act → observe** loop:
```bash
# Wait reactively for the chat view — never use sleep
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
# Act, then always observe the resulting screen state
bunx tuistory -s cline type "/settings"
bunx tuistory -s cline snapshot --trim
bunx tuistory -s cline press enter
bunx tuistory -s cline snapshot --trim
# Styled PNG of the current screen (prints the file path) — good for artifacts
bunx tuistory -s cline screenshot
# Full raw output stream (snapshot shows only the visible screen)
bunx tuistory read -s cline --all
# Tear down a session YOU started (double Ctrl+C exits the TUI cleanly)
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline close
```
## Background processes (instead of tmux)
```bash
bunx tuistory -s my-server -- bun run dev:sidecar # returns immediately
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
bunx tuistory read -s my-server # new output since last read
bunx tuistory -s my-server restart # after code changes
```
## Key rules
- **Options before `--`, command after.** Everything after the first `--` is passed verbatim to the child: `tuistory -s name --cols 150 -- bun src/index.ts` is correct.
- **Snapshot after every action.** TUIs are stateful; dialogs and errors can render over the view you expect. `snapshot` reflects what the user actually sees (occluded text does not count), unlike grepping the raw stream.
- **Wait, never sleep.** `wait "text"` / `wait "/regex/i"` (case-sensitive by default) reacts as fast as the terminal updates; `wait-idle` when you don't know what to expect. Always pass `--timeout`.
- **Keys land instantly.** Unlike sleep-based scripts, a queued second keypress can leak into the next view (e.g. one Enter both accepts a slash completion and submits it).
- **Never close a session you didn't start.** Sessions are shared with humans (`tuistory attach -s name`) and other agents. Default to leaving sessions running; use `read`/`wait`/`snapshot` to inspect without disrupting.
- `--cols`/`--rows` affect TUI layout (assertions are width-sensitive); `--pixel-ratio 2` gives sharper screenshots.
## Writing e2e tests with the library API
`apps/cli/src/cli.tuistory.e2e.test.ts` (run: `bun run test:e2e:tuistory`) is the reference. The programmatic API runs in-process — no daemon:
```ts
import { launchTerminal } from "tuistory";
const session = await launchTerminal({
command: "bun",
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
cwd: cliRoot,
env: isolatedEnv, // see createCliEnv() in the reference test
cols: 120,
rows: 36,
waitForDataTimeout: 30_000, // CLI cold start compiles a large TS graph
});
await session.waitForText("What can I do for you?", { timeout: 30_000 });
const screen = await session.text({ trimEnd: true }); // emulated screen state
await session.type("/settings");
await session.press("enter");
session.close(); // always close in test teardown
```
Screen-state assertions can check that stale UI is *gone* (`expect(screen).not.toContain(...)`), which stream-grepping harnesses cannot. `session.text({ only: { bold: true } })` filters by style; `session.read()` returns the raw stream since the last read.
+2 -3
View File
@@ -8,9 +8,8 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# 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"}'
-1
View File
@@ -16,7 +16,6 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- 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
+3 -7
View File
@@ -15,8 +15,6 @@ body:
- VSCode Extension
- JetBrains Plugin
- CLI
- Desktop App
- Cloud Platform
default: 0
validations:
required: true
@@ -64,15 +62,13 @@ body:
- type: textarea
id: ide-diagnostics
attributes:
label: Diagnostics
label: IDE / CLI Diagnostics
description: |
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
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.
- Desktop App: paste the app version from the Settings view.
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
-155
View File
@@ -1,155 +0,0 @@
name: Sign Windows CLI binaries
description: >
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
signatures. If the Azure Trusted Signing secrets are not configured, the
action logs a warning and exits successfully so releases keep working while
signing infrastructure is being provisioned.
inputs:
azure-client-id:
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
required: false
default: ""
azure-tenant-id:
description: Entra tenant ID.
required: false
default: ""
azure-subscription-id:
description: Azure subscription ID containing the Trusted Signing account.
required: false
default: ""
endpoint:
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
required: false
default: ""
account:
description: Trusted Signing account name.
required: false
default: ""
certificate-profile:
description: Trusted Signing certificate profile name.
required: false
default: ""
files:
description: Newline-separated list of PE files to sign.
required: true
runs:
using: composite
steps:
- name: Check signing configuration
id: check
shell: bash
env:
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
run: |
missing=()
set_count=0
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
if [ -z "${!var}" ]; then
missing+=("$var")
else
set_count=$((set_count + 1))
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
echo "enabled=true" >> "$GITHUB_OUTPUT"
elif [ "$set_count" -eq 0 ]; then
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
# Partial configuration is almost certainly a typo'd or renamed
# secret. Fail loudly instead of silently publishing unsigned.
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
exit 1
fi
- name: Azure login (OIDC)
if: steps.check.outputs.enabled == 'true'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Sign Windows binaries
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
FILES: ${{ inputs.files }}
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
set -euo pipefail
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
echo "::add-mask::${JSIGN_STOREPASS}"
export JSIGN_STOREPASS
# jsign expects the endpoint host, not the URL. Tolerate both the
# portal's display form (trailing slash) and the bare form.
KEYSTORE="${SIGNING_ENDPOINT#https://}"
KEYSTORE="${KEYSTORE%/}"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Signing ${file}"
java -jar "$JSIGN_JAR" \
--storetype TRUSTEDSIGNING \
--keystore "$KEYSTORE" \
--storepass env:JSIGN_STOREPASS \
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
--alg SHA-256 \
--tsaurl http://timestamp.acs.microsoft.com \
--tsmode RFC3161 \
--replace \
"$file"
done <<< "$FILES"
- name: Verify signatures
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
FILES: ${{ inputs.files }}
# Authenticode chains anchor to the Microsoft Identity Verification
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
# explicitly (pinned) for osslsigncode chain validation.
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
run: |
set -euo pipefail
if ! command -v osslsigncode >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq osslsigncode
fi
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Verifying signature on ${file}"
# Timestamp countersignature chain is checked separately by Windows;
# -ignore-timestamp only skips TSA chain validation here, not the
# Authenticode chain itself.
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
done <<< "$FILES"
+1 -56
View File
@@ -190,19 +190,6 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
@@ -219,8 +206,6 @@ jobs:
- name: Get Changelog Entry
id: changelog
env:
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
@@ -228,32 +213,6 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
# and link out to the full notes. The GitHub release body stays whole.
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -289,7 +248,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
@@ -460,20 +419,6 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
if: steps.check_commits.outputs.skip != 'true'
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
-928
View File
@@ -1,928 +0,0 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
channel:
description: "Release channel"
required: true
type: choice
options:
- stable
- beta
default: stable
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
channel: ${{ steps.version.outputs.channel }}
feed: ${{ steps.version.outputs.feed }}
product: ${{ steps.version.outputs.product }}
steps:
# Companion to the presence check in `build`, and the half that actually
# establishes scope. This job declares no environment, so a signing secret
# that resolves here can only be a repository or organization secret —
# meaning it is still readable by every workflow in the repo, which is the
# thing the PublishDesktop environment exists to prevent. Neither check
# proves provenance alone (an environment-gated job resolves repository
# secrets too, with environment values merely taking precedence), but
# together they do: empty here plus present in `build` means the value came
# from the environment.
- name: Verify signing secrets are not repository-scoped
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
unscoped=()
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -z "${!name}" ] || unscoped+=("$name")
done
if [ ${#unscoped[@]} -gt 0 ]; then
echo "These signing secrets resolve in a job with no environment:"
printf ' - %s\n' "${unscoped[@]}"
echo
echo "That means they are still repository or organization secrets and"
echo "are readable by any workflow in this repo. Delete them at that"
echo "level and add them to the PublishDesktop environment instead."
exit 1
fi
echo "No signing secret resolves outside the PublishDesktop environment."
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
# inputs.* (not github.event.inputs.*) so the declared default
# applies when an API dispatch omits the channel input entirely.
CHANNEL: ${{ inputs.channel }}
run: |
# Fail-closed channel mapping: every channel defines its tag shape,
# its ancestry source, its feed, and its product name, and an unknown
# channel dies here. The feed assignment is the load-bearing one —
# the updater comparator is a plain semver "newer than", so a beta
# manifest landing on desktop-latest would auto-update every stable
# install onto the beta. The stable regex rejects prerelease
# suffixes for the same reason.
case "$CHANNEL" in
stable)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "stable git_tag must look like desktop-vX.Y.Z with no suffix, got: ${TAG}"
exit 1
fi
ANCESTOR_REF=main
FEED=desktop-latest
PRODUCT="Cline"
;;
beta)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
echo "beta git_tag must look like desktop-vX.Y.Z-beta.N, got: ${TAG}"
exit 1
fi
ANCESTOR_REF=desktop-experimental
FEED=desktop-beta
PRODUCT="Cline Beta"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin "+${ANCESTOR_REF}:refs/remotes/origin/${ANCESTOR_REF}"
if ! git merge-base --is-ancestor "$HEAD_COMMIT" "origin/${ANCESTOR_REF}"; then
echo "${TAG} is not reachable from origin/${ANCESTOR_REF}"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
echo "feed=${FEED}" >> "$GITHUB_OUTPUT"
echo "product=${PRODUCT}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (universal)
needs: validate
# The Apple signing/notarization and Tauri updater secrets live in the
# PublishDesktop environment rather than at repository level, so they are
# readable only by this job and only once a required reviewer approves the
# run. Defense in depth: this `if` is advisory because a dispatched branch
# runs its own copy of this file; the enforced gate is the PublishDesktop
# environment's deployment-branch policy, which must also allow only main.
#
# Beta releases do not weaken this: a beta publish is ALSO dispatched from
# main (so this gate, the branch policy, and the workflow file executed all
# stay main's) — only the checked-out tag points into desktop-experimental,
# which validate pins via the ancestry check. A workflow copy edited on
# desktop-experimental can therefore never reach the signing secrets.
#
# What dispatch-from-main does NOT protect: the checked-out tag's own
# build scripts (bun install hooks, build:sdk, Tauri's beforeBuildCommand,
# build.rs) run inside this job with the signing secrets in scope, for
# stable and beta alike. The control for that is this environment's
# required-reviewer approval — the approver is vouching for the code the
# tag points at, not just for "a release happening". Two consequences:
# desktop-experimental must keep main-grade merge controls (branch
# protection, maintainer-only pushes), and an approval should only follow
# a look at what the tag actually contains. Building betas without these
# secrets is not an option: unsigned bundles fail Gatekeeper and updater
# artifacts must be signed with the same key or beta installs cannot
# verify their updates.
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: macos-latest
timeout-minutes: 90
steps:
# A secret missing here is dangerous rather than merely broken: Tauri skips
# code signing when APPLE_CERTIFICATE is empty and skips notarization when
# APPLE_API_KEY is empty, both silently, so the build would still succeed
# and publish an unsigned, un-notarized bundle. Only the missing updater
# key is caught later (by the .sig check in "Collect artifacts"). Fail up
# front instead, before any build work, if the environment is misconfigured.
- name: Verify PublishDesktop secrets are present
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing from the PublishDesktop environment:"
printf ' - %s\n' "${missing[@]}"
echo
echo "Check that every secret above is set on the PublishDesktop"
echo "environment and that this job still declares"
echo "'environment: PublishDesktop'."
exit 1
fi
# Deliberately not phrased as "resolved from PublishDesktop": a
# non-empty value here could also be a repository or organization
# secret. The repository-scope check in `validate` is what rules that
# out.
echo "All 8 signing secrets are present."
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
# A universal (fat) macOS bundle needs both architecture slices, so
# install both Rust targets; `tauri build --target universal-apple-darwin`
# compiles each and lipos the results into one binary.
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
# No Rust build cache here, deliberately. This is the only job that can
# read the Apple signing certificate and the Tauri updater key, and a
# restored cache archive is attacker-controlled the moment the Actions
# cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
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 }}
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
# Tauri merges repeated --config flags in order, so the beta overlay
# (product name, bundle identifier, beta update feed) layers on top of
# the release overlay without duplicating it. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
# this step and inlines these values into the binary via `--define`
# (scripts/telemetry-define-args.ts); a packaged app launched from
# Finder/the Dock has no runtime env, so build-time inlining is the
# only way the shipped sidecar can ever report telemetry.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
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 }}
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Tauri lipos the main binary itself but sidecars are merged by our own
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
# carries both slices before anything is published. A single-arch
# sidecar would otherwise ship fine and only crash on the other arch.
- name: Verify bundle is a universal binary
working-directory: apps/examples/desktop-app
env:
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
if [ ! -d "$APP" ]; then
echo "app bundle not found at $APP"
exit 1
fi
for bin in "$APP/Contents/MacOS/"*; do
archs=$(lipo -archs "$bin")
echo "$bin: $archs"
case "$archs" in
*arm64*x86_64*|*x86_64*arm64*) ;;
*)
echo "$bin is not a universal binary (archs: $archs)"
exit 1
;;
esac
done
# Guardrail: the updater endpoint is compiled into the main binary as a
# string literal (tauri-build embeds the merged config via codegen), so
# assert the bundle carries this channel's feed URL and not the other
# channel's, before anything gets signed into a release. This catches a
# --config overlay that silently failed to apply: a beta bundle polling
# desktop-latest would pull its users onto stable builds, and a stable
# bundle polling desktop-beta would push betas to every stable install.
- name: Verify updater feed endpoint
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
# Plain grep >/dev/null rather than grep -q: -q exits at the first
# match, SIGPIPEs strings, and would read as a failed pipeline under
# pipefail.
found=0
for bin in "$APP/Contents/MacOS/"*; do
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if strings -a "$bin" | grep "$WANT" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No binary in ${APP}/Contents/MacOS embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
# the --define inlining) would otherwise ship a release with telemetry
# silently disabled — exactly what happened for every release before
# this check existed. Being enabled is not enough on its own: an empty,
# malformed, or non-http(s) OTLP endpoint would still drop every event
# at runtime (the SDK exporters speak OTLP http/json only), so the
# selfcheck must also report a usable endpoint host.
- name: Verify sidecar telemetry config was inlined
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build, sign, and notarize desktop bundle' step and the"
echo "--define inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
echo "every event would be dropped at runtime. Check the"
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/${PREFIX}_${VERSION}_universal.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-universal
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
build-windows:
name: Build Windows (x64)
needs: validate
# Same gate rationale as the macOS build job above. This job additionally
# needs id-token: write for Azure OIDC: Windows binaries are
# Authenticode-signed with Azure Trusted Signing, authenticated through the
# PublishDesktop-environment federated credential on the cline-cli-signing
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: windows-latest
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
# All-or-nothing: an unsigned Windows desktop build is never acceptable
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
# unsigned installers), and Tauri would skip updater-artifact signing
# silently if the updater key were missing. Unlike the CLI pipeline
# there is no unsigned fallback here.
- name: Verify signing secrets are present
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing signing secrets for the Windows desktop build:"
printf ' - %s\n' "${missing[@]}"
echo
echo "The AZURE_* names are repository secrets; the TAURI_* names"
echo "live in the PublishDesktop environment. Refusing to build an"
echo "unsigned Windows desktop release."
exit 1
fi
echo "All Windows signing secrets are present."
# Every action in this job is SHA-pinned (unlike elsewhere in this
# file): they run with id-token: write and the updater signing key in
# scope, so a hijacked upstream tag must not be able to reach the
# signing identity or tamper with what gets signed and uploaded.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
# With a SHA-pinned action the toolchain no longer comes from the
# ref name, so it must be set explicitly.
toolchain: stable
# No Rust build cache, mirroring the macOS job: this job holds the
# updater signing key and an Azure signing session, and a restored cache
# archive is attacker-controlled if the Actions cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
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 }}
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
- name: Write signing config overlay
shell: bash
run: |
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
cat > "$SIGN_CONF" <<EOF
{
"\$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
}
}
}
EOF
cat "$SIGN_CONF"
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
- name: Build and sign desktop bundle
shell: bash
working-directory: apps/examples/desktop-app
# NSIS only: the MSI (WiX) target adds nothing for direct-download
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
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 }}
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
# Azure Trusted Signing; the token comes from the azure/login session)
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
# Updater artifact signing (minisign keypair, same key as macOS)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Same guardrail as the macOS job: assert the compiled binary embeds
# this channel's updater feed URL and not the other channel's. Checked
# on the unbundled main exe because NSIS compresses the installer
# contents, which defeats a string search on the installer itself.
- name: Verify updater feed endpoint
shell: bash
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
shell: bash
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
if [ -z "$SETUP" ]; then
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
exit 1
fi
# The .sig is the updater (minisign) signature; without it the
# manifest generator cannot publish a windows-x86_64 entry.
if [ ! -f "${SETUP}.sig" ]; then
echo "updater signature missing next to $SETUP"
exit 1
fi
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
ls -lh "$OUT"
# Independent Authenticode gate on the exact artifact users download.
# The signing script already verifies each file it signs, but this step
# would still catch an installer that skipped signCommand entirely.
- name: Verify Authenticode signatures
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
}
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
# Grab content between this release's "## <version>" header and the
# next one. Exact match, not "first section": once main and
# desktop-experimental cross-merge, stable and beta sections
# interleave and the top section may belong to the other channel.
CONTENT=$(awk -v ver="$VERSION" '$0 == "## " ver {found=1; next} /^## [0-9]/ {if (found) exit} found {print}' apps/examples/desktop-app/CHANGELOG.md)
if [ -z "$CONTENT" ]; then
echo "No '## ${VERSION}' section found in apps/examples/desktop-app/CHANGELOG.md"
exit 1
fi
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body and updater manifest stay whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
# Stable compare links skip beta tags so they read stable -> stable;
# beta compares against whatever shipped last on either channel.
if [ "$CHANNEL" = "stable" ]; then
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
else
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
fi
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
prerelease: ${{ needs.validate.outputs.channel == 'beta' }}
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHANNEL: ${{ needs.validate.outputs.channel }}
FEED: ${{ needs.validate.outputs.feed }}
run: |
# Belt and braces: recompute the feed from the channel and require it
# to agree with validate's output, so no single threading bug can
# point a publish at the other channel's feed. Stable installs poll
# desktop-latest and beta installs poll desktop-beta; crossing the
# streams either pushes betas to every stable user or strands beta
# users on stale builds.
case "$CHANNEL" in
stable) EXPECTED_FEED=desktop-latest ;;
beta) EXPECTED_FEED=desktop-beta ;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
if [ "$FEED" != "$EXPECTED_FEED" ]; then
echo "feed mismatch: validate says '${FEED}' but channel '${CHANNEL}' expects '${EXPECTED_FEED}'"
exit 1
fi
if ! gh release view "$FEED" >/dev/null 2>&1; then
if [ "$CHANNEL" = "beta" ]; then
gh release create "$FEED" \
--title "Cline desktop beta (auto-update feed)" \
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
--latest=false \
--prerelease \
--target "$(git rev-parse HEAD)"
else
gh release create "$FEED" \
--title "Cline desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
fi
gh release upload "$FEED" dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
FEED: ${{ needs.validate.outputs.feed }}
run: |
echo "Published Cline desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
- 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 desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — ${{ needs.validate.outputs.channel == 'beta' && 'beta channel: installs side by side with the stable app and only beta installs auto-update; stable users are unaffected' || 'installed apps auto-update on next launch' }}${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
-50
View File
@@ -1,50 +0,0 @@
name: desktop-test
on:
push:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
pull_request:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
dmg-background:
name: Test DMG background tooling
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/examples/desktop-app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
# The suite only uses Bun/Node built-ins and committed artwork, so it does
# not need a workspace dependency install or macOS runner.
- name: Test DMG background tooling
run: bun run test:dmg-background
-581
View File
@@ -1,581 +0,0 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
#
# Job layout: cheap input gates (preflight) and the two bundle test suites run
# ungated; the build job packages the VSIX with no environment attached, so
# publish=false rehearsals complete without any approval; only the publish job
# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
publish:
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
# Input gates that need no checkout: fail in seconds — before the test
# suites, the ~20-minute build, and the environment approval — instead of
# at publish time.
preflight:
name: Validate inputs
runs-on: ubuntu-latest
steps:
# The input reaches the shell ONLY via env here (never inline
# expression interpolation, which is evaluated before bash runs and
# would allow script injection from the dispatch form). Because
# every later job `needs` preflight, passing this regex is what
# makes the plain-string `${{ inputs.version }}` interpolations
# downstream safe.
- name: Validate version format
env:
VERSION: ${{ github.event.inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')."
echo "It is stamped verbatim into the union manifest and both bundle manifests."
exit 1
fi
echo "Version format ok: $VERSION"
# The reusable bun suite tests the dispatch revision (main), so
# publishing any other next-ref would ship an untested bundle.
# Build-only runs (publish=false) may still use arbitrary next-refs
# for artifact rehearsals.
- name: Refuse to publish an untested next-ref
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
run: |
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
exit 1
# Marketplace versions are monotonic and cannot be unpublished:
# every publish must exceed the highest version ever published to
# the claude-dev listing FROM ANY BRANCH (combined stable or legacy
# hotfix). The publish job re-checks right before publishing — the
# environment-approval wait can last days and a legacy hotfix can
# land in between. Keep both copies of this check in sync.
- name: Verify version exceeds the live Marketplace version
if: ${{ github.event.inputs.publish == 'true' }}
env:
VERSION: ${{ github.event.inputs.version }}
run: |
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
if [[ -z "$LIVE" ]]; then
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
exit 1
fi
node -e '
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
for (let i = 0; i < 3; i++) {
if (next[i] > live[i]) process.exit(0);
if (next[i] < live[i]) break;
}
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
process.exit(1);
' "$VERSION" "$LIVE"
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
# standalone publish paths (nightly gates on the bun suite via the same
# reusable workflow; the legacy publish inlines the npm suite).
#
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
# DISPATCH revision — main's tip at dispatch, since this workflow is only
# dispatched from main — not `next-ref`. The build job therefore pins the
# default next-ref checkout to that same revision (tested == built) and
# preflight refuses publish=true for any other next-ref; build-only artifact
# runs may still build untested refs.
test-next:
name: Test next (SDK) bundle
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/ext-vscode-test.yml
# The legacy branch is the npm codebase, so the bun-based reusable workflow
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
test-legacy:
name: Test legacy bundle
runs-on: ubuntu-latest
# The tested revision, exported so the build job builds EXACTLY what
# this suite ran against. legacy-extension is a mutable branch name and
# the build job starts later — re-resolving the name there could pick
# up commits this gate never saw.
outputs:
tested-sha: ${{ steps.rev.outputs.sha }}
defaults:
run:
working-directory: apps/vscode
steps:
# Always the protected legacy-extension branch — deliberately not
# an input. An arbitrary ref here would be built into the published
# VSIX by the environment-less build job, and the publish
# environment approver only ever sees an opaque prebuilt artifact:
# the approval would protect the marketplace PAT but not the
# shipped bytes. Hardcoding the branch makes its protection rules
# load-bearing for releases. Legacy hotfix testing has its own
# workflow (ext-vscode-publish-legacy.yml).
- uses: actions/checkout@v4
with:
ref: legacy-extension
- name: Record tested revision
id: rev
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- 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 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
build:
name: Build combined (legacy + next) VSIX
needs: [preflight, test-next, test-legacy]
runs-on: ubuntu-latest
steps:
# For the default next-ref (main), pin the checkout to the exact
# revision the test-next gate ran against: a moving branch name could
# otherwise drift past the tested commit during the test phase.
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
path: next-src
lfs: true
# Fail fast (before the ~20-min build) if a real publish is missing
# its changelog entry — same contract the standalone publish
# workflows enforce. Build-only rehearsals are exempt.
- name: Verify changelog entry
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: next-src
run: |
EXPECTED_HEADING="## [${{ github.event.inputs.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 (found '$FIRST_HEADING')."
exit 1
fi
echo "Found changelog entry for ${{ github.event.inputs.version }}"
# Pin to the revision test-legacy actually tested (see that job's
# outputs comment) — never re-resolve the mutable branch name here.
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ needs.test-legacy.outputs.tested-sha }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# --frozen-lockfile so the built bundle resolves the exact
# dependency set the test-next gate ran against (the reusable suite
# installs frozen too) — a bare install could silently re-resolve.
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
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"
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
publish:
name: Publish to Marketplace and Open VSX
needs: build
if: ${{ github.event.inputs.publish == 'true' }}
runs-on: ubuntu-latest
environment: publish
# contents: write is required by the post-publish bookkeeping (tag +
# GitHub Release), mirroring the standalone publish workflows.
permissions:
contents: write
steps:
# The built next revision: preflight refused publish=true for any
# next-ref other than main, and the build job pinned main to the
# dispatch SHA — so github.sha IS the published commit. Used for the
# changelog, the release tag, and the previous-tag lookup.
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Download VSIX artifact
uses: actions/download-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Re-check monotonicity at the last moment: the environment-approval
# wait can last days, and a legacy hotfix published in the meantime
# would otherwise be silently superseded by this older code line.
# Keep in sync with the preflight copy of this check.
- name: Re-verify version exceeds the live Marketplace version
env:
VERSION: ${{ github.event.inputs.version }}
run: |
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
if [[ -z "$LIVE" ]]; then
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
exit 1
fi
node -e '
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
for (let i = 0; i < 3; i++) {
if (next[i] > live[i]) process.exit(0);
if (next[i] < live[i]) break;
}
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
process.exit(1);
' "$VERSION" "$LIVE"
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
# Both PATs are verified BEFORE the first irreversible publish so a
# missing Open VSX token can't strand us half-published. The two
# registries are separate steps: if Open VSX fails after the
# Marketplace accepted the VSIX, the run goes red (so the operator
# notices Open VSX lagged) but the bookkeeping below still runs —
# it is keyed off the Marketplace outcome, which is what "shipped"
# means for this listing.
- name: Publish to Marketplace
id: publish_marketplace
working-directory: staging
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."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish to Open VSX."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Publish to Open VSX
working-directory: staging
env:
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT"
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
# Mirrors the standalone publish workflows. Every step here is
# continue-on-error, and gated on the MARKETPLACE outcome rather
# than plain step ordering: the Marketplace publish already
# happened, so bookkeeping must still run when only the Open VSX
# step failed, and a red run after a successful publish is exactly
# the confusion the nightly workflow taught us to avoid (tag pushes
# fail whenever the built commit touches .github/workflows/** — no
# grantable permission fixes that; push the tag manually in that
# case, see the publish-extension skill).
- name: Extract changelog entry
id: changelog
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
{
echo "content<<CHANGELOG_EOF"
echo "$CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
{
echo "slack_content<<CHANGELOG_EOF"
echo "$SLACK_CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
# ls-remote needs no local tag objects; take the highest v* tag
# below the one being released.
PREV=$(git ls-remote --tags origin 'v*' \
| awk -F/ '{print $NF}' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vx "v${{ github.event.inputs.version }}" \
| sort -V | tail -1)
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
- name: Create and push release tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
TAG="v${{ github.event.inputs.version }}"
git tag "$TAG" HEAD
git push origin "refs/tags/$TAG"
echo "Pushed $TAG at $(git rev-parse HEAD)"
- name: Create GitHub Release
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ github.event.inputs.version }}
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline v${{ github.event.inputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline v${{ github.event.inputs.version }}*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
@@ -1,327 +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
# Read-only by default. The publish job elevates itself to contents: write for
# the tag push and GitHub release; nothing here needs packages/checks/PR
# write. Keeping the default minimal matters doubly in this workflow because
# the test job runs BEFORE any environment approval — it must never hold a
# write token while executing checked-out code.
permissions:
contents: read
concurrency:
group: ext-vscode-publish-legacy
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:
# Always the protected legacy-extension branch — deliberately not
# an input. This job runs full npm lifecycle scripts from the
# checked-out code with no environment approval, and the publish
# job below does the same next to the marketplace PATs; an
# arbitrary ref here would hand both of them attacker-controlled
# code. Hardcoding the branch makes its protection rules
# load-bearing for releases.
- uses: actions/checkout@v4
with:
ref: legacy-extension
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- 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 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
# For the tag push in Resolve Release Tag and the GitHub release.
permissions:
contents: write
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main; hardcoded — see the test
# job's checkout comment). fetch-depth: 0 + tags so we can
# create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: legacy-extension
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: legacy-extension
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
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_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.slack_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 }}"
+47 -218
View File
@@ -1,43 +1,14 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
# Manual dispatch only. The nightly cron was removed deliberately: the
# PublishNightly environment gained required reviewers, and an unattended
# cron run would just sit `waiting` on that approval, hold this workflow's
# concurrency group, and silently cancel every later scheduled run behind it
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -46,7 +17,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline'
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
pull-requests: read
@@ -56,80 +27,60 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
name: Publish Cline (Nightly) Extension
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
steps:
- name: Checkout next (SDK) source
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: the || fallback is retained so this stays correct if a
# non-dispatch trigger is ever added back (inputs are empty strings
# on e.g. `schedule` events, where the declared default does not apply).
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
- name: Show build source
working-directory: ${{ github.workspace }}
run: |
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
echo "Building ref: $GITHUB_REF"
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 required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
# 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:
node-version: 22
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: next-src
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -139,24 +90,20 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
# 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
- name: Build next bundle
working-directory: next-src/apps/vscode
- name: Publish Nightly Extension
env:
CLINE_ENVIRONMENT: production
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -164,129 +111,12 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
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."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
working-directory: ${{ github.workspace }}
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -294,11 +124,10 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+20 -93
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,13 +102,7 @@ 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}"
@@ -180,87 +170,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
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -301,6 +210,24 @@ jobs:
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:
@@ -330,7 +257,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+8 -9
View File
@@ -80,9 +80,8 @@ jobs:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
# Nothing in this job uses OIDC, so it does not need an id-token
# permission.
permissions:
id-token: write
contents: read
defaults:
run:
@@ -94,9 +93,6 @@ jobs:
with:
bun-version: 1.3.14
# Cache keys below are exact-match only (no restore-keys prefix
# fallbacks); a miss just means a cold install, which is acceptable.
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
@@ -104,6 +100,8 @@ jobs:
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
@@ -112,6 +110,8 @@ jobs:
with:
path: apps/vscode/.vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
# Cache Playwright browsers
- name: Cache Playwright browsers
@@ -123,6 +123,8 @@ jobs:
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
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).
@@ -169,12 +171,9 @@ jobs:
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
# Repo-root relative: the job's `working-directory` default applies to `run`
# steps only, so an apps/vscode-relative path here silently matches nothing
# and every failing run uploads no recordings at all.
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
apps/vscode/test-results/
test-results/playwright/
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.101.0
VSCODE_TEST_VERSION: 1.103.0
strategy:
fail-fast: false
matrix:
@@ -1,60 +0,0 @@
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
# and GitHub offers no per-behavior control over an installed App, so the ad
# cannot be disabled at the source. This deletes those promo comments as they
# appear. Genuine agent output comments (work results, reviews) don't match the
# promo pattern and are left alone.
#
# No checkout, API-calls-only — comment text is only ever handled as data inside
# the script, never interpolated into the workflow definition.
name: repo-delete-agent-promo-comments
on:
issue_comment:
types: [created]
jobs:
delete:
runs-on: ubuntu-latest
timeout-minutes: 2
# Prefilter so a runner only spins up for bot comments that look like the
# ad; the script re-verifies before deleting.
if: >-
github.event.issue.pull_request &&
endsWith(github.event.comment.user.login, '[bot]') &&
contains(github.event.comment.body, 'can help with this pull request')
# Comment deletion goes through the issues API, but GitHub gates the
# endpoint by where the comment lives: issue comments need `issues`,
# PR-conversation comments need `pull-requests`. The prefilter restricts
# this job to PR comments, so pull-requests is the one that matters;
# issues is kept in case the prefilter is ever widened.
permissions:
issues: write
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions and fires on attacker-postable events.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const comment = context.payload.comment
// Belt and suspenders on top of the job-level prefilter: only
// delete when the author is a real GitHub App bot AND the body
// matches the self-promotion shape ("... can help with this
// pull request. Just @<handle> ..."). A human quoting the ad
// text is not a Bot; a bot posting real work output doesn't
// match the promo shape.
const isBot = comment.user.type === "Bot"
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
if (!isBot || !isPromo) {
core.info("not an agent promo comment, leaving it alone")
return
}
await github.rest.issues.deleteComment({
...context.repo,
comment_id: comment.id,
})
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
@@ -1,65 +0,0 @@
# Cloud coding agents append promotional badge blocks to PR bodies after the
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
# marker comments. The agent itself never sees that content, so no repo rule or
# agent instruction can prevent it. This strips it from the PR description on
# open/edit, keeping only the agent-authored content between the markers.
#
# Uses pull_request_target so the token has write access on PRs from forks. That
# trigger is only unsafe when a job checks out and executes PR code — this one
# never checks out the repository, it only calls the REST API.
name: repo-strip-agent-badges
on:
pull_request_target:
types: [opened, edited]
concurrency:
group: strip-agent-badges-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
strip:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
permissions:
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions under pull_request_target.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
// Re-fetch instead of trusting the event payload: the body may have
// been edited again between the event firing and this run (agent
// harnesses edit PR bodies post-open), and updating from the stale
// snapshot would clobber the newer content.
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
})
const body = pr.body || ""
// The BEGIN/END comments wrap the agent-authored content; everything
// outside them (vendor promo badges, "open in <tool>" links) is
// appended by the harness. Keep only what's between the markers.
// The backreference requires BEGIN and END to name the same vendor.
// No markers -> no match -> body passes through unchanged.
const cleaned = body
.replace(
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
"$2",
)
.trimEnd()
// No change means a previous run already cleaned this body. Returning
// without an update is what stops `edited` from retriggering forever.
if (cleaned === body) {
core.info("nothing to strip")
return
}
await github.rest.pulls.update({
...context.repo,
pull_number: pr.number,
body: cleaned,
})
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
-85
View File
@@ -260,68 +260,6 @@ jobs:
git push origin "refs/tags/${TAG}"
done
- name: Get Previous SDK Tag
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: prev_tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# The checkout is shallow and tagless, so fetch the release tags explicitly.
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
id: changelog
run: |
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
DELIMITER=$(openssl rand -hex 8)
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
with:
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
name: "SDK v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
@@ -342,26 +280,3 @@ jobs:
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
- name: Post release to Slack
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline SDK v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline SDK v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
-150
View File
@@ -1,150 +0,0 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --frozen-lockfile
# @cline/ui imports @cline/shared/browser (generated-media), which
# resolves to dist output — build it before anything typechecks or
# builds the ui package.
- name: Build shared package
run: bun -F @cline/shared build
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
-9
View File
@@ -85,12 +85,3 @@ apps/vscode/webview-ui/src/**/*.js.map
.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/src-tauri/dmg/background.gen.tiff
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
apps/examples/desktop-app/.cursor/settings.json
+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 -22
View File
@@ -36,13 +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.
**All events should be named using snake_case and so should their properties**
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
@@ -87,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);
@@ -95,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
@@ -125,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
+2 -4
View File
@@ -51,8 +51,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "staging",
"CLINE_DIR": "${userHome}/.cline_staging"
"CLINE_ENVIRONMENT": "staging"
}
},
{
@@ -76,8 +75,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "local",
"CLINE_DIR": "${userHome}/.cline_local"
"CLINE_ENVIRONMENT": "local"
}
},
{
+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
+9 -29
View File
@@ -68,7 +68,7 @@
"command": "bun run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -89,7 +89,7 @@
"command": "bun run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -114,16 +114,16 @@
{
"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": "."
}
}
],
@@ -169,8 +169,7 @@
"isBackground": true,
"label": "npm: watch:esbuild",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -209,8 +208,7 @@
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -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": [
-32
View File
@@ -1,32 +0,0 @@
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
## Cloud Agent Instructions
### Cline CLI
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
### Build / Lint / test
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
### GUI display
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
### VS Code extension (`apps/vscode`, package `claude-dev`)
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
-419
View File
@@ -1,424 +1,5 @@
# Changelog
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
- New files are now created with your platform's native line endings.
- Fixed the codebase search tool crashing on files containing a single enormous line.
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
- The hub's event log can no longer grow until it fills your disk.
### Changed
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
## [4.1.15]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
## [4.1.14]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
### Fixed
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
## [4.1.13]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
## [4.1.12]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
## [4.1.11]
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
### Added
- Let models that support it generate images during a task. Generated images render inline in the conversation.
### Fixed
- Fix code actions failing with "command not found" on VS Code 1.134.
- Fix `@` file mentions breaking on paths that contain spaces.
- Show the diff edit view for multi-line edits in files with CRLF line endings.
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
- Honor the classic truncation range when migrating legacy tasks.
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
- Point provider signup links at each provider's API key page instead of a generic landing page.
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
- Stop offering image, voice, and other non-chat models in chat model pickers.
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
### Changed
- Show the billed cost for Cline gateway usage.
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
### Fixed (legacy bundle)
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
## [4.1.10]
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
### Added
- Let models that support it search the web during a task, with a toggle in Feature Settings to turn it on. Search calls and their results appear in the conversation and persist across reloads.
### Fixed
- Stop two Cline installations on different builds from shutting each other's Hub daemon down in a loop, which killed live sessions with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can decide to retire the other.
- Leave a Hub that is still serving sessions in place instead of replacing it mid-handshake; the swap happens once it goes idle.
- Reclaim idle plugin sandbox processes instead of leaving them running for the life of the session.
### Changed
- Refresh the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
## [4.1.9]
### Changed
- Use the editor's foreground color for diff block text, so diffs stay legible in themes where the previous hardcoded color washed them out.
- Switch the interface to Inter and Geist Mono.
### Fixed
- Don't discard a successfully refreshed Cline token when the old one was already past expiry, which made the first request after a long idle period fail despite valid credentials.
- Stop the legacy-task migration backlog from spamming telemetry, and record a migration outcome only once the seeded session actually persists, so a failed migration is no longer reported as a success.
- Report involuntary Cline logouts (a rejected refresh token) instead of clearing credentials silently.
### Fixed (SDK bundle only)
These land through SDK v0.0.74 and therefore apply to windows running the SDK bundle, not the legacy one.
- Fix the Claude Code provider being unusable for agentic work: it now runs its own native tools instead of receiving tool definitions it cannot bridge, anchors the session on your workspace directory, and loads `~/.claude` plus project settings so your permission rules apply.
- Reject truncated tool-call JSON instead of silently "repairing" it into wrong arguments.
- Fix strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts.
- Fix a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough.
- Report disjoint per-request token buckets instead of re-counting the whole cached conversation on every request, which inflated per-task totals roughly 5x on cache-heavy sessions.
## [4.1.8]
### Added
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
- Support Fable 5 on Vertex.
### Changed
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
### Fixed
- Respect your configured max output tokens when the compaction summarizer requests a summary.
- Remove the stale "Double-Check Completion" feature tip.
## [4.1.7]
### Added
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
- Bring back a copy button on turn-final response rows.
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
### Changed
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
### Fixed
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
- Settle the turn phase when a mode switch aborts a running turn.
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
## [4.1.6]
### Added
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
### Fixed
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
## [4.1.5]
### Added
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
### Changed
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
## [4.1.4]
### Added
- Recognize Chutes as a provider.
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
### Changed
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
### Fixed
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
- Show installed plugins under their real package names instead of all appearing as "index".
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
## [4.1.3]
### Fixed
- Stop the two bundles of the combined rollout package from invalidating each other's Cline account session. A still-open legacy window that refreshed its token after the machine was promoted to the new extension would consume the shared refresh token, producing spurious "Unauthorized" / re-authenticate prompts and unexpected sign-outs. Promoted legacy windows now keep working on their current session and offer a one-time Reload Window prompt instead.
- Fall back to the default Cline model when migrating a setup that references a model id the new extension doesn't recognize, instead of leaving the provider unconfigured.
- Restore reliable checkpoints: checkpoints are created consistently, and restoring one now rewinds the whole workspace rather than a subset of files.
- Keep settings edits that are made before the provider config finishes loading — base URLs, API keys, and the Qwen/Moonshot API line are no longer silently discarded.
- Stop losing keystrokes in custom base URL fields, and keep the custom URL checkbox state after a failed clear.
- Use the AskSage custom API URL at inference time instead of ignoring it.
- Settle a pending tool approval when an edited message replaces the session, so the task no longer hangs waiting on a prompt that is gone.
- Drop attachments from messages that have been edited.
- Complete terminal commands when the shell execution ends, so tasks no longer stall on commands that already finished.
- Include untracked files when generating commit messages.
- Run Windows Store PowerShell profiles correctly.
- Surface the upstream provider error when a gateway-forwarded stream fails, instead of a generic failure.
- Retry empty Ollama responses at the model boundary, and raise the response-start timeout to 5 minutes so cold model loads no longer error out.
- Show proper display names for Cline free models and recommended models in the model picker.
- Preserve video input capability for models that support it.
- Keep the plan/act input border in sync with the actual textarea focus.
## [4.1.2]
### Added
- Show which extension variant is active — "Legacy" or "Next" — next to the version in the settings About page, in both bundles of the combined rollout package.
## [4.1.1]
### Changed
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
## [4.1.0]
### Changed
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
## [4.0.12]
### Added
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
### Fixed
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
## [4.0.11]
### Added
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
- Add Moonshot Kimi K3 support.
- Include the host plugin version in telemetry events.
### Fixed
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
- Enable native tool calling for Kimi K3 models, fixing empty responses.
## [4.0.10]
### Added
- Add telemetry to track when Cline reaches the consecutive mistake limit.
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [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
+1 -1
View File
@@ -7,7 +7,7 @@ We're thrilled you're interested in contributing to Cline. Whether you're fixing
Bug reports help make Cline better for everyone! Before creating a new issue, please [search existing ones](https://github.com/cline/cline/issues) to avoid duplicates. When you're ready to report a bug, head over to our [issues page](https://github.com/cline/cline/issues/new/choose) where you'll find a template to help you with filling out the relevant information.
<blockquote class='warning-note'>
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">GitHub security tool to report it privately</a>.
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
</blockquote>
+5 -5
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 |
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
```bash
cline "Run tests and fix any failures"
git diff origin/main | cline "Review these changes for issues"
git diff origin/main | cline "Review these changes for issues"
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
```
-337
View File
@@ -1,342 +1,5 @@
# Cline CLI Changelog
## 3.0.61
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
- `apply_patch` now preserves a file's existing CRLF line endings
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
- Fixed scheduled tasks disappearing after a hub restart
- Fixed markdown flashing as it settled at the end of a streamed response
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
## 3.0.60
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 3.0.58
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
## 3.0.57
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
## 3.0.56
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
- Fixed TUI dialog colors not following theme changes live
- Fixed the account dialog's selection chevron so it matches the other dialogs
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
- Fixed Gemini custom base URLs configured as a host root
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 3.0.55
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
- Added protections for an update landing under CLI 3.0.54 and earlier, whose updater restarts the Hub mid-session and then rejects every replacement, bricking a running session. The newly installed package defuses that path during install instead of leaving it to fire
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, which killed every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other (from SDK v0.0.75)
- A newer build no longer replaces a Hub that is still serving sessions — it attaches to it and the swap happens on a later launch, instead of the sessions dying mid-handshake (from SDK v0.0.75)
- Removed the "outdated Hub" notice. It reported a state you cannot act on, and the toast was capped narrower than the message, so it rendered cut off before the reassuring half of the sentence at every terminal width. The prompt for a genuine build mismatch, where there is something to do, is unchanged
- Streaming assistant markdown no longer flashes back to raw text. Settled headings, links, and code stay rendered as new chunks arrive instead of the whole message being rebuilt and re-highlighted on every chunk, which also stops the transcript from jumping vertically mid-stream
- Web search calls and their results from models that run search natively now render in the transcript (from SDK v0.0.75)
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session (from SDK v0.0.75)
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked (from SDK v0.0.75)
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board (from SDK v0.0.75)
## 3.0.54
- Fixed the Claude Code provider being unusable for agentic work: the provider now runs its own native tools instead of receiving tool definitions it cannot bridge, the session is anchored on your workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so your permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings (from SDK v0.0.74)
- Fixed truncated tool-call JSON being silently "repaired" into wrong arguments — a payload with an unterminated string is now rejected rather than getting an invented terminator (from SDK v0.0.74)
- Fixed strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts (from SDK v0.0.74)
- Fixed a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough (from SDK v0.0.74)
- Managed Hub daemons now upgrade directionally: when another Cline install ships a newer Hub build, the CLI attaches to the newer daemon and prompts you to update and restart instead of the two installs repeatedly retiring each other's daemons. Yolo and sandbox sessions, which never attach to the shared Hub, are not interrupted by that prompt (from SDK v0.0.74)
- Fixed the Hub daemon logging an unhandled `hub server close failed` error and exiting non-zero whenever a client was still connected at shutdown (from SDK v0.0.74)
- Fixed per-task token totals being inflated roughly 5x on cache-heavy sessions — token telemetry now reports disjoint uncached-input, cache-read, and cache-write buckets instead of re-counting the whole cached conversation on every request (from SDK v0.0.74)
- Upgrading the CLI now retires an already-running Hub daemon and respawns it on the new code, instead of the upgraded CLI continuing to talk to a daemon executing the previous release
## 3.0.53
- Fixed the CLI reconnecting to a stale Hub daemon after an upgrade. Hub daemons now carry a runtime build fingerprint, so an upgraded CLI retires and respawns a daemon still running older code instead of attaching to it (from SDK v0.0.73)
- Fixed compaction being silently skipped on reasoning models. The summarizer no longer hardcodes a 1024-token output cap — it honors your max output tokens setting, defaults to 4096 (lowered when the model reports less), and logs a diagnostic when a summary comes back empty (from SDK v0.0.73)
- Added Fable 5 (`claude-fable-5`) to the Vertex model catalog. Pricing is intentionally omitted because Vertex bills region-dependently, so cost shows as unknown rather than wrong (from SDK v0.0.73)
- Custom Vertex model IDs are now passed through unchanged, routing Claude-style IDs to the Anthropic-on-Vertex path (from SDK v0.0.73)
## 3.0.52
- Added `cline mcp uninstall` for removing an installed MCP server
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
## 3.0.51
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
## 3.0.50
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
- Auto-approval settings are now honored over ACP
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
## 3.0.49
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
- Checkpoints are actually created again; a run-boundary regression meant none were ever recorded in the CLI (from SDK v0.0.69)
- Checkpoint restore is now a full workspace rewind: files Cline created during the task come back at their checkpoint-time content and files created after the checkpoint are removed, while `.gitignore`d paths (build output, `node_modules`, `.env`) are left alone (from SDK v0.0.69)
- After a restore, the rewound message is prefilled as plain text instead of the raw `<user_input mode="act">` envelope
- Ollama's response-start timeout is now 5 minutes instead of 30 seconds, so cold-loading a large local model no longer errors out mid-load (from SDK v0.0.69)
- Empty Ollama responses are now retried instead of failing the task with "Model returned empty response" (from SDK v0.0.69)
- Migrated users whose stored Cline model id isn't in the catalog now fall back to the default model instead of sending an unknown model id on every request (from SDK v0.0.69)
- The ClinePass promo dialog can be dismissed with any key (Enter still opens the subscription page), and it is marked as shown when it appears, so force-quitting no longer replays it on every launch
- Opening a URL no longer crashes the CLI on hosts without an opener binary (headless Linux without `xdg-open`); WSL2 containers now use `xdg-open`, Windows tries the absolute PowerShell path first, and `cline doctor log` converts Linux paths to `\\wsl$` UNC paths
- The hub now restarts through the installed wrapper after a Unix self-update, so npm cannot reuse a deleted cached executable
- ACP: ClinePass is selectable as a provider, organizations can be selected, session resolution and text rendering on session restart are fixed, and agent errors now describe the actual failure
- Provider errors forwarded through the Vercel AI Gateway now surface the real upstream message instead of a raw Zod dump or `[object Object]` (from SDK v0.0.68)
- Cline free models and recommended models now show their real display names in the model picker (from SDK v0.0.68)
- Sessions rooted at the filesystem root (`/`) no longer fail every command (from SDK v0.0.68)
- On Windows, PowerShell commands now travel over UTF-8 stdin, so non-ASCII commands survive the active code page and long commands are not capped by the command-line limit (from SDK v0.0.68)
- The live model catalog no longer drops the video input capability (from SDK v0.0.68)
- Removed the CLI promo code flow
## 3.0.48
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
- `cline --help` now reports the real default `--config` and `--data-dir` paths
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
- Aborting a task no longer risks killing the shared hub daemon
- Connector status delivery failures are no longer fatal to the turn
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
- Updated the bundled model catalog (from SDK v0.0.66)
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
- Fixed provider config not reloading when switching models
- Fixed auto-update failing to detect Bun global installs after symlink resolution
- Fixed unexpected logouts caused by transient network or server errors during token refresh
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
- Session id is now preserved when continuing within the same session
- Hardened context compaction budget handling
## 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)
-31
View File
@@ -339,9 +339,6 @@ bun run test:e2e:interactive
# TUI-specific E2E tests (uses @microsoft/tui-test)
bun run test:e2e:cli:tui
# TUI E2E tests driven through tuistory (PTY + Ghostty terminal emulator)
bun run test:e2e:tuistory
# Type checking
bun run typecheck
@@ -367,34 +364,6 @@ bun run dev -- --interactive --config /tmp/cline-test
Or set `CLINE_FORCE_ONBOARDING=1` to force the onboarding view regardless of existing config.
### Manually testing the TUI (agents / headless environments)
[tuistory](https://github.com/remorses/tuistory) is installed as a devDependency. It wraps the TUI in a named background PTY session that can be scripted from a plain shell — no real terminal or display needed. This is the preferred way for AI agents (or anyone in a headless environment) to poke at the interactive TUI:
```bash
cd apps/cli
# Launch the TUI in a background session
bunx tuistory -s cline --cols 120 --rows 36 -- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
# Wait reactively for the chat view (no sleep guessing)
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
# Interact and inspect
bunx tuistory -s cline type "/settings"
bunx tuistory -s cline press enter
bunx tuistory -s cline snapshot --trim # current screen as text
bunx tuistory -s cline screenshot # current screen as a styled PNG
# A human can watch/drive the same session from another terminal
tuistory attach -s cline
# Tear down
bunx tuistory -s cline close
```
The same engine powers the `test:e2e:tuistory` vitest suite (`src/cli.tuistory.e2e.test.ts`), which uses the programmatic `launchTerminal()` API for assertions against the emulated screen.
### Adding a new TUI component
1. Create a `.tsx` file in `src/tui/components/`
-3
View File
@@ -270,9 +270,6 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
### Windows
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
### Windows code signing
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
### File permissions
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
+4 -21
View File
@@ -221,15 +221,13 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
Schedule agents on cron-like intervals or external events.
If `--provider` and `--model` are omitted, schedules use the last configured
provider and model. If only `--provider` is given, the schedule uses that
provider's saved model.
```sh
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
@@ -259,10 +257,10 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
| `--kanban` | Run the external `kanban` app |
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
@@ -348,24 +346,9 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
-281
View File
@@ -1,281 +0,0 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
-65
View File
@@ -23,48 +23,6 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
@@ -72,29 +30,6 @@ function run(target) {
});
if (result.error) {
console.error(result.error.message);
// Windows application control (Smart App Control, WDAC, AppLocker)
// blocks the child exe at launch, which Node surfaces only as an
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
const code = result.error.code;
if (
os.platform() === "win32" &&
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
) {
console.error(
"\nWindows refused to start the Cline binary:\n " +
target +
"\n\n" +
"This usually means an application control policy (Smart App Control,\n" +
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
"run the path above directly in a terminal and check the error Windows\n" +
"reports, or inspect its signature with:\n\n" +
' Get-AuthenticodeSignature "' +
target +
'"\n\n' +
"If it was blocked by policy, allow the file or ask your administrator\n" +
"to trust it. See https://github.com/cline/cline/issues for known issues.",
);
}
process.exit(1);
}
if (typeof result.status === "number") {
+1 -1
View File
@@ -121,7 +121,7 @@ const result = await Bun.build({
},
env: "OTEL_*",
banner:
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
});
if (result.logs.length > 0) {
+7 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.61",
"version": "3.0.27",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -62,7 +62,6 @@
"test:unit": "vitest run --config vitest.config.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts",
"test:e2e:tuistory": "vitest run --config vitest.tuistory.e2e.config.ts",
"test:watch": "vitest --config vitest.config.ts",
"test:e2e:cli:tui": "cd src/tests && tui-test",
"link": "bun unlink && bun link"
@@ -79,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.7",
"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.33.0",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
@@ -100,9 +99,8 @@
"@cline/core": "workspace:*",
"@cline/shared": "workspace:*",
"@microsoft/tui-test": "^0.0.2",
"@types/bun": "^1.3.10",
"@types/react": "19.2.14",
"tuistory": "^0.10.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"@types/bun": "^1.3.10"
}
}
-37
View File
@@ -17,35 +17,6 @@ import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// CLI versions <= 3.0.54 restart the hub daemon after a background
// auto-update even while it is serving live sessions, killing those sessions
// mid-turn — and their build-fingerprint check then rejects every replacement
// hub, bricking the running TUI. That restart code is the *old* version's, so
// it cannot be patched here; but it bails out harmlessly when no hub
// discovery record exists, and it runs only after this install (and this
// script) completes. Setting the record aside protects any attached clients:
// a running hub keeps serving its established connections, clients that share
// its build fingerprint rebuild the record from a port probe, and the next
// fresh launch retires stale hubs regardless of the record.
function shieldRunningHubDiscovery() {
const explicitPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
const dataDir =
process.env.CLINE_DATA_DIR?.trim() ||
path.join(
process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline"),
"data",
);
const recordPath =
explicitPath || path.join(dataDir, "locks", "hub", "production.json");
if (!fs.existsSync(recordPath)) {
return;
}
const asidePath = `${recordPath}.superseded`;
fs.rmSync(asidePath, { force: true });
fs.renameSync(recordPath, asidePath);
console.log("Set aside hub discovery record for the updated CLI");
}
function main() {
if (os.platform() === "win32") {
// On Windows, npm creates .cmd shims from the bin field.
@@ -108,14 +79,6 @@ function main() {
console.log(`Cached cline binary at ${target}`);
}
try {
shieldRunningHubDiscovery();
} catch (error) {
// Best-effort: without the shield the worst case is the pre-3.0.55
// restart-while-busy behavior, never a broken install.
console.error(`postinstall: hub discovery shield skipped: ${error.message}`);
}
try {
main();
} catch (error) {
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# Launch the Cline CLI in ACP mode from source, for use as a Zed custom agent.
#
# Zed spawns agents without your interactive shell's PATH, so `bun` (installed
# via mise/asdf/nvm/homebrew) is usually not resolvable. This wrapper finds bun
# explicitly and execs it from the repo root.
#
# IMPORTANT: stdout is the JSON-RPC channel. Never echo to stdout here — any
# stray byte corrupts the ACP stream. Diagnostics go to stderr.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
# Prefer an explicit override, then PATH, then common version-manager locations.
if [ -n "${BUN_BIN:-}" ]; then
bun_bin="$BUN_BIN"
elif command -v bun > /dev/null 2>&1; then
bun_bin="$(command -v bun)"
else
bun_bin=""
for candidate in \
"$HOME"/.local/share/mise/installs/bun/*/bin/bun \
"$HOME"/.bun/bin/bun \
/opt/homebrew/bin/bun \
/usr/local/bin/bun; do
if [ -x "$candidate" ]; then
bun_bin="$candidate"
break
fi
done
fi
if [ -z "$bun_bin" ]; then
echo "acp-dev.sh: could not find the 'bun' executable; set BUN_BIN to its path" >&2
exit 127
fi
cd "$REPO_ROOT"
exec "$bun_bin" --conditions=development --cwd apps/cli dev --acp "$@"
+39 -352
View File
@@ -7,8 +7,6 @@ import type {
ContentBlock,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
@@ -30,12 +28,11 @@ import {
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import { isLikelyAuthError, type MessageWithMetadata } from "@cline/shared";
import type { Message } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
import { createCliCore } from "../session/session";
import { isClineOrgIndividualInferenceSubscriptionErrorMessage } from "../utils/cline-pass-errors";
import { getCliBuildInfo } from "../utils/common";
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
import type { Config } from "../utils/types";
@@ -46,34 +43,14 @@ import {
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
import {
buildOrganizationConfigOption,
fetchClineOrganizations,
getAcpOrgSubscriptionMessage,
ORGANIZATION_CONFIG_ID,
PERSONAL_ACCOUNT_VALUE,
switchClineOrganization,
usesClineAccount,
} from "./organizations";
import { requestAcpToolApproval } from "./permissions";
import { replaySessionHistory } from "./session-load";
import {
describeAgentError,
forwardAgentEvent,
sendConfigOptionUpdate,
sendCurrentModeUpdate,
sendSessionInfoUpdate,
} from "./session-updates";
const CHAT_MODEL_QUERY_OPTIONS = {
filter: "chat",
} satisfies Llms.GetModelsForProviderOptions;
interface SessionState {
id: string;
cwd: string;
@@ -84,8 +61,6 @@ interface SessionState {
currentProviderId: string;
/** Current model id for the session. */
currentModelId: string;
/** When true, all tool calls are approved without asking the client. */
autoApproveTools: boolean;
/** Active session manager for the running agent, if any. */
sessionManager?: ClineCore;
/** Internal session id within the session manager. */
@@ -94,34 +69,20 @@ interface SessionState {
abortController?: AbortController;
/** Unsubscribe function for the agent event listener. */
unsubscribe?: () => void;
/**
* Most recent unrecoverable agent error for the in-flight turn.
*
* The runtime reports fatal failures (bad credentials, subscription
* restrictions, provider outages) as an `error` event and still resolves
* `send()` normally, so the message has to be stashed here for `prompt()` to
* turn into an error response.
*/
fatalError?: Error;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: MessageWithMetadata[];
pendingInitialMessages?: Message[];
}
export class AcpAgent implements Agent {
private sessions = new Map<string, SessionState>();
private readonly conn: AgentSideConnection;
private readonly providerSettingsManager = new ProviderSettingsManager();
private readonly defaultAutoApproveTools: boolean;
/** Set after a successful `authenticate` call. */
private authResult?: AcpAuthResult;
constructor(
conn: AgentSideConnection,
options?: { autoApproveTools?: boolean },
) {
constructor(conn: AgentSideConnection) {
this.conn = conn;
this.defaultAutoApproveTools = options?.autoApproveTools ?? false;
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
@@ -148,7 +109,7 @@ export class AcpAgent implements Agent {
};
}
isSessionReady() {
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
// Require authentication unless an API key is provided via env var.
if (!this.authResult && !process.env.CLINE_API_KEY) {
// Check for valid persisted credentials from a previous session
@@ -158,49 +119,18 @@ export class AcpAgent implements Agent {
if (!this.authResult) {
throw RequestError.authRequired(
undefined,
"Call authenticate before starting a session",
"Call authenticate before creating a session",
);
}
}
}
availableModes() {
return [
{
id: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
id: "act",
name: "Act",
description: "Make changes to the codebase",
},
];
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
this.isSessionReady();
const sessionId = randomSessionId();
const defaultMode = "act";
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
// Model ids are provider-scoped, so the default must come from the
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
// nothing to `cline`, and vice versa.
const defaultModelId = await resolveDefaultModelId(
providerId,
process.env.CLINE_MODEL,
providerModels,
);
const defaultModelId =
process.env.CLINE_MODEL ?? "anthropic/claude-sonnet-4.6";
this.sessions.set(sessionId, {
id: sessionId,
@@ -209,9 +139,9 @@ export class AcpAgent implements Agent {
currentMode: defaultMode,
currentProviderId: providerId,
currentModelId: defaultModelId,
autoApproveTools: this.defaultAutoApproveTools,
});
const providerModels = await Llms.getModelsForProvider(providerId);
const availableModels = Object.entries(providerModels).map(
([modelId, info]) => ({
modelId,
@@ -220,13 +150,22 @@ export class AcpAgent implements Agent {
}),
);
const organizationOption =
await this.getOrganizationConfigOption(providerId);
return {
sessionId,
modes: {
availableModes: this.availableModes(),
availableModes: [
{
id: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
id: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
currentModeId: defaultMode,
},
models: {
@@ -237,91 +176,10 @@ export class AcpAgent implements Agent {
await buildProviderConfigOption(providerId),
buildModelConfigOption(defaultModelId, providerModels),
buildModeConfigOption(defaultMode),
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
...(organizationOption ? [organizationOption] : []),
],
};
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.isSessionReady();
let session = this.sessions.get(params.sessionId);
let messages: MessageWithMetadata[];
if (session?.sessionManager && session.activeSessionId) {
// The session is still live in this connection — replay its current
// conversation without restarting anything.
messages =
(await session.sessionManager.readMessages(session.activeSessionId)) ??
[];
} else {
if (!session) {
// Provider/model are not persisted per session — a session
// loaded on a fresh connection starts from the same defaults
// as a new session, with the model resolved against the
// provider's own catalog just like newSession.
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
session = {
id: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
currentMode: "act",
currentProviderId: providerId,
currentModelId: await resolveDefaultModelId(
providerId,
process.env.CLINE_MODEL,
providerModels,
),
autoApproveTools: this.defaultAutoApproveTools,
};
this.sessions.set(params.sessionId, session);
}
try {
messages =
(await this.ensureSessionManager(session, params.sessionId, {
resume: true,
})) ?? [];
} catch (error) {
this.sessions.delete(params.sessionId);
throw error;
}
}
// The ACP spec requires the full conversation to be replayed via
// session/update notifications before this request resolves.
await replaySessionHistory(this.conn, params.sessionId, messages);
const providerModels = await Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
);
const availableModels = Object.entries(providerModels).map(
([availableModelId, info]) => ({
modelId: availableModelId,
name: info.name ?? availableModelId,
description: info.description,
}),
);
return {
modes: {
availableModes: this.availableModes(),
currentModeId: session.currentMode,
},
models: {
availableModels,
currentModelId: session.currentModelId,
},
configOptions: await buildAllConfigOptions(session),
};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
@@ -335,7 +193,6 @@ export class AcpAgent implements Agent {
const abortController = new AbortController();
session.abortController = abortController;
session.fatalError = undefined;
// If cancel() was already called before prompt() started, bail early.
if (abortController.signal.aborted) {
@@ -385,17 +242,6 @@ export class AcpAgent implements Agent {
updatedAt: new Date().toISOString(),
});
// A cancelled turn always reports `cancelled`: the ACP spec
// requires agents to convert abort failures into the cancelled stop reason
// so clients don't show cancellations as errors.
if (stopReason !== "cancelled") {
const fatalError = session.fatalError;
session.fatalError = undefined;
if (fatalError) {
throw toAcpPromptError(fatalError);
}
}
return { stopReason };
}
@@ -480,40 +326,16 @@ export class AcpAgent implements Agent {
// creates a fresh one with the new provider on the next prompt().
await this.teardownSessionManager(session);
// Re-resolve the model against the new provider's catalog: keep the
// current one when it's offered there too, otherwise fall back to the
// provider's declared default rather than whichever model happens to
// be listed first (for cline-pass that is an unrelated free model).
const providerModels = await Llms.getModelsForProvider(
value,
CHAT_MODEL_QUERY_OPTIONS,
);
session.currentModelId = await resolveDefaultModelId(
value,
session.currentModelId,
providerModels,
);
break;
}
case ORGANIZATION_CONFIG_ID: {
try {
await switchClineOrganization({
apiKey: this.accountApiKey,
providerSettingsManager: this.providerSettingsManager,
organizationId: value === PERSONAL_ACCOUNT_VALUE ? null : value,
});
} catch (error) {
const message = describeAgentError(error);
throw RequestError.internalError(
{ message },
`Failed to switch account: ${message}`,
);
// If current model doesn't exist in new provider, reset to first available
const providerModels = await Llms.getModelsForProvider(value);
const modelIds = Object.keys(providerModels);
const fallbackModelId = modelIds[0];
if (
!modelIds.includes(session.currentModelId) &&
fallbackModelId !== undefined
) {
session.currentModelId = fallbackModelId;
}
// Restart the backend session so subsequent turns run under the
// newly selected account.
await this.teardownSessionManager(session);
break;
}
@@ -540,18 +362,6 @@ export class AcpAgent implements Agent {
break;
}
case AUTO_APPROVE_CONFIG_ID: {
const autoApprove = parseAutoApproveValue(params.value);
if (autoApprove === undefined) {
throw RequestError.invalidParams(
undefined,
`Invalid auto-approve value: ${String(params.value)} (must be a boolean)`,
);
}
session.autoApproveTools = autoApprove;
break;
}
default:
throw RequestError.invalidParams(
undefined,
@@ -560,12 +370,6 @@ export class AcpAgent implements Agent {
}
const configOptions = await buildAllConfigOptions(session);
const organizationOption = await this.getOrganizationConfigOption(
session.currentProviderId,
);
if (organizationOption) {
configOptions.push(organizationOption);
}
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
return { configOptions };
}
@@ -606,25 +410,6 @@ export class AcpAgent implements Agent {
this.sessions.clear();
}
private get accountApiKey(): string {
return process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
}
private async getOrganizationConfigOption(
providerId: string,
): Promise<SessionConfigOption | undefined> {
if (!usesClineAccount(providerId)) {
return undefined;
}
const organizations = await fetchClineOrganizations({
apiKey: this.accountApiKey,
providerSettingsManager: this.providerSettingsManager,
});
return organizations
? buildOrganizationConfigOption(organizations)
: undefined;
}
/**
* Attempt to restore authentication from persisted provider settings.
*
@@ -682,17 +467,13 @@ export class AcpAgent implements Agent {
* Lazily create and start the session manager for this ACP session.
* After the first call the manager persists across prompt() calls so that
* conversation history is maintained.
*
* With `resume: true` the persisted conversation for `acpSessionId` is read
* back through the session manager.
*/
private async ensureSessionManager(
session: SessionState,
acpSessionId: string,
options?: { resume?: boolean },
): Promise<MessageWithMetadata[] | undefined> {
): Promise<void> {
if (session.sessionManager) {
return undefined;
return;
}
const config = await this.buildConfig(session);
@@ -701,66 +482,35 @@ export class AcpAgent implements Agent {
toolPolicies: config.toolPolicies,
capabilities: {
requestToolApproval: (request) =>
session.autoApproveTools
? Promise.resolve({ approved: true })
: requestAcpToolApproval(this.conn, acpSessionId, request),
requestAcpToolApproval(this.conn, acpSessionId, request),
},
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
});
let initialMessages: MessageWithMetadata[] | undefined;
if (options?.resume) {
initialMessages = await sessionManager
.readMessages(acpSessionId)
.catch(() => undefined);
if (!initialMessages || initialMessages.length === 0) {
await sessionManager
.dispose("acp_load_session_not_found")
.catch(() => {});
throw RequestError.resourceNotFound(acpSessionId);
}
} else {
initialMessages = session.pendingInitialMessages;
session.pendingInitialMessages = undefined;
}
session.unsubscribe = subscribeToAgentEvents(
sessionManager,
(event: AgentEvent) => {
// Remember unrecoverable failures so prompt() can fail the turn.
if (event.type === "error" && !event.recoverable) {
session.fatalError =
event.error instanceof Error
? event.error
: new Error(describeAgentError(event.error));
}
forwardAgentEvent(this.conn, acpSessionId, event);
},
);
const initialMessages = session.pendingInitialMessages;
session.pendingInitialMessages = undefined;
const started = await sessionManager.start({
source: SessionSource.CLI,
// Persist the core session under the ACP session id so that
// session/load can find the conversation by the id the client holds.
config: {
...config,
modelId: session.currentModelId,
sessionId: acpSessionId,
},
config,
interactive: true,
initialMessages,
});
session.sessionManager = sessionManager;
session.activeSessionId = started.sessionId;
return initialMessages;
}
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 ?? "";
@@ -769,7 +519,6 @@ export class AcpAgent implements Agent {
providerId,
mode: session.currentMode,
});
const cliBuildInfo = getCliBuildInfo();
return {
providerId,
@@ -788,69 +537,11 @@ 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),
};
}
}
async function resolveDefaultModelId(
providerId: string,
preferredModelId: string | undefined,
providerModels: Record<string, unknown>,
): Promise<string> {
const modelIds = Object.keys(providerModels);
const preferred = preferredModelId?.trim();
if (preferred && modelIds.includes(preferred)) {
return preferred;
}
const providerDefault = (await Llms.getProvider(providerId))?.defaultModelId;
if (providerDefault && modelIds.includes(providerDefault)) {
return providerDefault;
}
return modelIds[0] ?? "";
}
/**
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
*
* Credential/subscription problems map to `auth_required` (-32000) so clients
* can offer a re-auth affordance rather than just printing text; everything
* else is an internal error.
*
* Classification goes through the shared CLI helpers, which check the error's
* type *and* its name/message. That matters because the runtime re-wraps errors
* as it forwards them across the event boundary, so `instanceof` alone fails on
* the object ACP actually receives.
*/
function toAcpPromptError(error: Error): RequestError {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
const message = getAcpOrgSubscriptionMessage();
return RequestError.internalError({ message }, message);
}
const message = describeAgentError(error);
const isAuthProblem = isLikelyAuthError(error);
return isAuthProblem
? RequestError.authRequired({ message }, message)
: RequestError.internalError({ message }, message);
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
@@ -921,16 +612,12 @@ async function buildAllConfigOptions(
): Promise<SessionConfigOption[]> {
const [providerOption, providerModels] = await Promise.all([
buildProviderConfigOption(session.currentProviderId),
Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
),
Llms.getModelsForProvider(session.currentProviderId),
]);
return [
providerOption,
buildModelConfigOption(session.currentModelId, providerModels),
buildModeConfigOption(session.currentMode),
buildAutoApproveConfigOption(session.autoApproveTools),
];
}
+1 -5
View File
@@ -5,13 +5,9 @@ import { writeDiagnostic } from "../utils/output";
/**
* Supported ACP OAuth provider IDs.
*
* This list doubles as the set of selectable providers (see
* `setSessionConfigOption`)
*/
export const ACP_AUTH_METHODS = [
{ id: "cline", name: "Sign in with Cline" },
{ id: "cline-pass", name: "Sign in with ClinePass" },
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
] as const;
@@ -34,7 +30,7 @@ async function performOAuthLogin(input: {
providerSettingsManager: ProviderSettingsManager;
}): Promise<string> {
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
[import("@cline/core"), import("../utils/open")],
[import("@cline/core"), import("open")],
);
const callbacks = createOAuthClientCallbacks({
-47
View File
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
describe("buildAutoApproveConfigOption", () => {
it("builds a boolean config option reflecting the current value", () => {
const option = buildAutoApproveConfigOption(true);
expect(option).toMatchObject({
type: "boolean",
id: AUTO_APPROVE_CONFIG_ID,
currentValue: true,
});
expect(option.name).toBeTruthy();
});
it("defaults to disabled when the session has it off", () => {
const option = buildAutoApproveConfigOption(false);
expect(option).toMatchObject({ type: "boolean", currentValue: false });
});
});
describe("parseAutoApproveValue", () => {
it("accepts booleans", () => {
expect(parseAutoApproveValue(true)).toBe(true);
expect(parseAutoApproveValue(false)).toBe(false);
});
it("accepts the string forms sent by older clients", () => {
expect(parseAutoApproveValue("true")).toBe(true);
expect(parseAutoApproveValue("false")).toBe(false);
});
it("fails closed for unrecognized values", () => {
expect(parseAutoApproveValue("yes")).toBeUndefined();
expect(parseAutoApproveValue(1)).toBeUndefined();
expect(parseAutoApproveValue(null)).toBeUndefined();
});
it("returns undefined when no value was provided", () => {
expect(parseAutoApproveValue(undefined)).toBeUndefined();
});
});
-32
View File
@@ -1,32 +0,0 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
export const AUTO_APPROVE_CONFIG_ID = "auto_approve";
export function buildAutoApproveConfigOption(
currentValue: boolean,
): SessionConfigOption {
return {
type: "boolean",
id: AUTO_APPROVE_CONFIG_ID,
name: "Auto-approve tools",
description:
"Automatically approve all tool calls without asking for permission",
currentValue,
};
}
/**
* Interpret the value of a `session/set_config_option` request for the
* auto-approve option.
*
* The ACP schema sends booleans for boolean options, but clients that predate
* boolean options may send the string form, so both are accepted. Returns
* `undefined` for anything else so the caller can reject the request.
*/
export function parseAutoApproveValue(value: unknown): boolean | undefined {
if (typeof value === "boolean" || value === undefined) {
return value;
}
return value === "true" ? true : value === "false" ? false : undefined;
}
+2 -8
View File
@@ -1,11 +1,7 @@
import { Readable, Writable } from "node:stream";
import { writeDiagnostic } from "../utils/output";
export interface AcpModeOptions {
autoApproveTools?: boolean;
}
export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
export async function runAcpMode(): Promise<void> {
const { AgentSideConnection, ndJsonStream } = await import(
"@agentclientprotocol/sdk"
);
@@ -19,9 +15,7 @@ export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
);
const connection = new AgentSideConnection((conn) => {
return new AcpAgent(conn, {
autoApproveTools: options?.autoApproveTools,
});
return new AcpAgent(conn);
}, stream);
// Keep the process alive until the connection closes
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildOrganizationConfigOption,
PERSONAL_ACCOUNT_VALUE,
} from "./organizations";
describe("buildOrganizationConfigOption", () => {
const organizations = [
{
active: false,
memberId: "m-1",
name: "Acme Corp",
organizationId: "org-1",
roles: ["member" as const],
},
{
active: true,
memberId: "m-2",
name: "Cline Bot Inc",
organizationId: "org-2",
roles: ["admin" as const],
},
];
it("lists Personal first plus every organization", () => {
const option = buildOrganizationConfigOption({
organizations,
activeOrganizationId: "org-2",
});
expect(option.id).toBe("organization");
if (option.type !== "select") {
throw new Error(`expected a select option, got ${option.type}`);
}
expect(option.currentValue).toBe("org-2");
expect(option.options).toEqual([
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
{ value: "org-1", name: "Acme Corp" },
{ value: "org-2", name: "Cline Bot Inc" },
]);
});
it("selects Personal when no organization is active", () => {
const option = buildOrganizationConfigOption({
organizations,
activeOrganizationId: null,
});
expect(option.currentValue).toBe(PERSONAL_ACCOUNT_VALUE);
});
});
-154
View File
@@ -1,154 +0,0 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import {
type ClineAccountOrganization,
ClineAccountService,
getPersistedProviderApiKey,
type ProviderSettingsManager,
RuntimeOAuthTokenManager,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export const PERSONAL_ACCOUNT_VALUE = "personal";
export const ORGANIZATION_CONFIG_ID = "organization";
export function usesClineAccount(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export interface AcpOrganizationState {
organizations: ClineAccountOrganization[];
/** Active organization id, or null when the personal account is active. */
activeOrganizationId: string | null;
}
interface ClineAccountInput {
apiKey: string;
providerSettingsManager: ProviderSettingsManager;
}
// Cline access tokens expire between runs, so account requests resolve
// through the refresh-aware OAuth manager. A single shared instance keeps
// refreshes single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let oauthTokenManager: RuntimeOAuthTokenManager | undefined;
function createAccountService(input: ClineAccountInput): ClineAccountService {
const { providerSettingsManager } = input;
const settings = providerSettingsManager.getProviderSettings("cline");
return new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => {
try {
oauthTokenManager ??= new RuntimeOAuthTokenManager({
providerSettingsManager,
});
const resolution = await oauthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces
// the auth failure to the caller.
}
return (
getPersistedProviderApiKey(
"cline",
providerSettingsManager.getProviderSettings("cline"),
) ||
input.apiKey ||
undefined
);
},
});
}
export async function fetchClineOrganizations(
input: ClineAccountInput,
): Promise<AcpOrganizationState | undefined> {
try {
const service = createAccountService(input);
const organizations = await service.fetchUserOrganizations();
if (organizations.length === 0) {
return undefined;
}
return {
organizations,
activeOrganizationId:
organizations.find((org) => org.active)?.organizationId ?? null,
};
} catch {
return undefined;
}
}
export function buildOrganizationConfigOption(
state: AcpOrganizationState,
): SessionConfigOption {
return {
type: "select",
id: ORGANIZATION_CONFIG_ID,
name: "Account",
description:
"The Cline account usage is billed to — your personal account or an organization",
category: "account",
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
options: [
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
...state.organizations.map((org) => ({
value: org.organizationId,
name: org.name,
})),
],
};
}
export async function switchClineOrganization(
input: ClineAccountInput & { organizationId: string | null },
): Promise<void> {
const service = createAccountService(input);
await service.switchAccount(input.organizationId);
await persistActiveOrganization(input.providerSettingsManager, service);
}
// Re-persist the active organization so headless runs and the hub daemon
// attribute telemetry to the right account. Best-effort: the switch itself
// already succeeded server-side.
async function persistActiveOrganization(
manager: ProviderSettingsManager,
service: ClineAccountService,
): Promise<void> {
try {
const organizations = await service.fetchUserOrganizations();
const active = organizations.find((org) => org.active) ?? null;
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
organizationId: active?.organizationId,
organizationName: active?.name,
memberId: active?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Ignore; see above.
}
}
export function getAcpOrgSubscriptionMessage(): string {
return [
"Organization accounts cannot use ClinePass subscriptions.",
'Switch the "Account" session option to Personal to keep using ClinePass,',
'or switch the "Provider" option to Cline to bill your organization.',
].join(" ");
}
-331
View File
@@ -1,331 +0,0 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
import {
replaySessionHistory,
translateHistoricalMessage,
} from "./session-load";
describe("translateHistoricalMessage", () => {
it("maps string content to a message chunk for the right role", () => {
expect(translateHistoricalMessage({ role: "user", content: "hi" })).toEqual(
[
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "hi" },
},
],
);
expect(
translateHistoricalMessage({ role: "assistant", content: "hello" }),
).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
},
]);
});
it("strips the <user_input> wrapper from replayed user text", () => {
// Persisted user messages keep their runtime-generated wrapper. Replaying
// it verbatim leaked markup to the client, which rendered the unknown
// element as bare text (a one-word prompt showed up as just its content
// with the wrapper swallowed).
expect(
translateHistoricalMessage({
role: "user",
content: '<user_input mode="act">s</user_input>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "s" },
},
]);
expect(
translateHistoricalMessage({
role: "user",
content: [
{
type: "text",
text: '<user_input mode="plan">lets do it</user_input>',
},
],
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "lets do it" },
},
]);
});
it("strips mode notices and formats slash commands for display", () => {
expect(
translateHistoricalMessage({
role: "user",
content:
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "are you okay?" },
},
]);
expect(
translateHistoricalMessage({
role: "user",
content:
'<user_command slash="team">spawn a team of agents for the following task: inspect rpc startup</user_command>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "/team inspect rpc startup" },
},
]);
});
it("does not replay the synthetic act-mode continuation prompt", () => {
expect(
translateHistoricalMessage({
role: "user",
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
}),
).toEqual([]);
});
it("leaves assistant text untouched", () => {
// Only user text carries the wrapper; agent output must replay verbatim.
expect(
translateHistoricalMessage({
role: "assistant",
content: 'Use <user_input mode="act"> to wrap prompts.',
}),
).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: 'Use <user_input mode="act"> to wrap prompts.',
},
},
]);
});
it("skips empty text and unknown blocks", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "redacted_thinking", data: "xxx" },
],
}),
).toEqual([]);
});
it("maps thinking blocks to agent_thought_chunk", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: [{ type: "thinking", thinking: "pondering" }],
}),
).toEqual([
{
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "pondering" },
},
]);
});
it("maps tool_use to a pending tool_call", () => {
const updates = translateHistoricalMessage({
role: "assistant",
content: [
{
type: "tool_use",
id: "call-1",
name: "read_files",
input: { file_paths: ["a.ts"] },
},
],
});
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "call-1",
kind: "read",
status: "pending",
rawInput: { file_paths: ["a.ts"] },
});
});
it("maps tool_result to a tool_call_update with flattened output", () => {
expect(
translateHistoricalMessage({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-1",
name: "read_files",
content: [
{ type: "text", text: "line one" },
{ type: "image", data: "abc", mediaType: "image/png" },
],
},
],
}),
).toEqual([
{
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "completed",
rawOutput: "line one\n[image]",
},
]);
});
it("marks errored tool results as failed", () => {
const [update] = translateHistoricalMessage({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-2",
name: "run_commands",
content: "boom",
is_error: true,
},
],
});
expect(update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call-2",
status: "failed",
rawOutput: "boom",
});
});
it("maps image blocks to image content chunks", () => {
expect(
translateHistoricalMessage({
role: "user",
content: [{ type: "image", data: "abc", mediaType: "image/png" }],
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "abc", mimeType: "image/png" },
},
]);
});
it("replays provider model tools with the ordinary ACP tool updates", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: "Found it",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun release" },
output: "Bun 1.3.14",
},
],
},
} as Parameters<typeof translateHistoricalMessage>[0]),
).toEqual([
{
sessionUpdate: "tool_call",
toolCallId: "search-1",
title: expect.any(String),
kind: "search",
status: "pending",
rawInput: { query: "latest Bun release" },
},
{
sessionUpdate: "tool_call_update",
toolCallId: "search-1",
status: "completed",
rawOutput: "Bun 1.3.14",
},
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Found it" },
},
]);
});
it("preserves structured native web-search results", () => {
const nativeResult = {
type: "web_search_result",
url: "https://bun.sh/blog/bun-v1.3.14",
title: "Bun v1.3.14",
pageAge: "2026-08-12",
encryptedContent: "encrypted",
};
const updates = translateHistoricalMessage({
role: "assistant",
content: "Found it",
metadata: {
modelToolActivities: [
{
toolCallId: "search-native",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun" },
output: [nativeResult],
},
],
},
} as Parameters<typeof translateHistoricalMessage>[0]);
expect(updates[1]).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "search-native",
rawOutput: JSON.stringify(nativeResult),
});
});
});
describe("replaySessionHistory", () => {
it("sends one awaited notification per update, in order", async () => {
const sent: unknown[] = [];
const conn = {
sessionUpdate: vi.fn(async (notification: unknown) => {
sent.push(notification);
}),
} as unknown as AgentSideConnection;
await replaySessionHistory(conn, "sess-1", [
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]);
expect(sent).toEqual([
{
sessionId: "sess-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "question" },
},
},
{
sessionId: "sess-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "answer" },
},
},
]);
});
});
-183
View File
@@ -1,183 +0,0 @@
import type {
AgentSideConnection,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import { projectSessionMessagesForDisplay } from "@cline/core";
import {
type ContentBlock,
formatDisplayUserInput,
type MessageWithMetadata,
type ToolResultContent,
} from "@cline/shared";
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
* The act-mode continuation prompt is runtime-generated, not typed by the
* user, so it must not replay as a user turn. Mirrors the TUI transcript
* hydration filter in tui/utils/hydrate-messages.ts.
*/
function isSyntheticUserText(text: string): boolean {
return text === ACT_MODE_CONTINUATION_PROMPT;
}
/**
* Replay a persisted conversation to the client as session/update
* notifications. Used by `session/load` — the ACP spec requires the entire
* conversation to be replayed before the load request resolves, so each
* notification is awaited.
*/
export async function replaySessionHistory(
conn: AgentSideConnection,
sessionId: string,
messages: MessageWithMetadata[],
): Promise<void> {
for (const message of messages) {
for (const update of translateHistoricalMessage(message)) {
await conn.sessionUpdate({ sessionId, update });
}
}
}
export function translateHistoricalMessage(
message: MessageWithMetadata,
): SessionUpdate[] {
return projectSessionMessagesForDisplay([message]).flatMap(({ message }) =>
translateProjectedHistoricalMessage(message),
);
}
function translateProjectedHistoricalMessage(
message: MessageWithMetadata,
): SessionUpdate[] {
const blocks: ContentBlock[] =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
: message.content;
const updates: SessionUpdate[] = [];
for (const block of blocks) {
switch (block.type) {
case "text": {
if (!block.text) break;
if (message.role !== "user") {
updates.push({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: block.text },
});
break;
}
// Display boundary: persisted user text keeps its runtime-generated
// <user_input mode="..."> wrapper and <mode_notice> elements (they are
// the durable record of the mode each turn was sent in). Replaying them
// verbatim leaks markup to the client, which renders the unknown
// element as bare text — so `s` shows up as `s` with the wrapper
// swallowed. Strip them the same way every other surface does.
const text = formatDisplayUserInput(block.text);
if (!text || isSyntheticUserText(text)) break;
updates.push({
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
});
break;
}
case "thinking": {
if (!block.thinking) break;
updates.push({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: block.thinking },
});
break;
}
case "image": {
const content = {
type: "image" as const,
data: block.data,
mimeType: block.mediaType,
};
updates.push(
message.role === "user"
? { sessionUpdate: "user_message_chunk", content }
: { sessionUpdate: "agent_message_chunk", content },
);
break;
}
case "media": {
const media = block.media;
if (media.modality === "image" && media.source.type === "base64") {
updates.push({
sessionUpdate:
message.role === "user"
? "user_message_chunk"
: "agent_message_chunk",
content: {
type: "image",
data: media.source.data,
mimeType: media.mediaType,
},
});
} else {
updates.push({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `[Generated ${media.modality}: ${media.mediaType}]`,
},
});
}
break;
}
case "tool_use": {
updates.push({
sessionUpdate: "tool_call",
toolCallId: block.id,
title: buildToolTitle(block.name, block.input),
kind: mapToolKind(block.name),
status: "pending",
rawInput: block.input,
});
break;
}
case "tool_result": {
updates.push({
sessionUpdate: "tool_call_update",
toolCallId: block.tool_use_id,
status: block.is_error ? "failed" : "completed",
rawOutput: flattenToolResultContent(block.content),
});
break;
}
default:
break;
}
}
return updates;
}
function flattenToolResultContent(
content: ToolResultContent["content"],
): string {
if (typeof content === "string") {
return content;
}
return content
.map((part) => {
switch (part.type) {
case "text":
return part.text;
case "file":
return part.content;
case "image":
return "[image]";
default:
try {
return JSON.stringify(part);
} catch {
return String(part);
}
}
})
.join("\n");
}
-34
View File
@@ -1,34 +0,0 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { forwardAgentEvent } from "./session-updates";
describe("forwardAgentEvent", () => {
it("forwards generated images as ACP agent message chunks", () => {
const sessionUpdate = vi.fn().mockResolvedValue(undefined);
const connection = { sessionUpdate } as unknown as AgentSideConnection;
forwardAgentEvent(connection, "session-1", {
type: "content_end",
contentType: "media",
media: {
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: { type: "base64", data: "aGVsbG8=" },
},
} as AgentEvent);
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "image",
data: "aGVsbG8=",
mimeType: "image/png",
},
},
});
});
});
-31
View File
@@ -4,8 +4,6 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import type { GeneratedMedia } from "@cline/shared";
import { getErrorMessage } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
@@ -83,11 +81,6 @@ function translateContentStart(
}
}
export function describeAgentError(error: unknown): string {
const message = getErrorMessage(error).trim();
return message || "The agent reported an unknown error.";
}
function translateContentEnd(
event: AgentEvent & { type: "content_end" },
): SessionUpdate[] {
@@ -101,7 +94,6 @@ function translateContentEnd(
output?: unknown;
error?: string;
durationMs?: number;
media?: GeneratedMedia;
};
switch (e.contentType) {
@@ -111,29 +103,6 @@ function translateContentEnd(
case "reasoning":
// Reasoning was already streamed via content_start chunks; don't re-send.
return [];
case "media":
if (!e.media) return [];
if (e.media.modality !== "image" || e.media.source.type !== "base64") {
return [
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `[Generated ${e.media.modality}: ${e.media.mediaType}]`,
},
},
];
}
return [
{
sessionUpdate: "agent_message_chunk",
content: {
type: "image",
data: e.media.source.data,
mimeType: e.media.mediaType,
},
},
];
case "tool": {
const toolCallId = e.toolCallId ?? "unknown";
const failed = !!e.error;
-1
View File
@@ -17,7 +17,6 @@ const TOOL_KIND_MAP: Record<string, ToolKind> = {
WebFetch: "fetch",
fetch_web_content: "fetch",
WebSearch: "search",
web_search: "search",
Agent: "think",
spawn_agent: "think",
NotebookEdit: "edit",
-367
View File
@@ -1,367 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
-65
View File
@@ -767,71 +767,6 @@ Break work into clear steps.`,
);
});
it("routes mcp uninstall and its rm alias", () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
tempDirs.push(tempRoot);
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
const writeSettings = () => {
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: { transport: { type: "stdio", command: "node" } },
remote: {
transport: {
type: "streamableHttp",
url: "https://mcp.example.com",
},
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
};
const readServers = () =>
(
JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
}
).mcpServers ?? {};
writeSettings();
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(uninstallResult.status).toBe(0);
expect(asText(uninstallResult.stdout)).toContain(
"Uninstalled MCP server docs.",
);
expect(Object.keys(readServers())).toEqual(["remote"]);
writeSettings();
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(aliasResult.status).toBe(0);
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
name: "remote",
status: "uninstalled",
});
expect(Object.keys(readServers())).toEqual(["docs"]);
writeSettings();
const missingResult = runCli(["mcp", "remove", "missing"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(missingResult.status).toBe(1);
expect(asText(missingResult.stderr)).toContain(
'MCP server "missing" is not installed.',
);
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+1 -1
View File
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
"claude-sonnet-4-6",
"-k",
"test-key",
"seed history session",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
-244
View File
@@ -1,244 +0,0 @@
// ---------------------------------------------------------------------------
// Proof-of-concept: driving the interactive TUI with tuistory
// (https://github.com/remorses/tuistory) instead of `script` + timed printf.
//
// Compare with `cli.interactive.e2e.test.ts`, which pipes keystrokes through
// the Unix `script` utility on a fixed sleep schedule and greps the raw
// output dump. Here each test launches the CLI in a real PTY backed by a
// Ghostty terminal emulator, waits reactively for screen content
// (`waitForText` resolves as soon as the text renders), and asserts against
// the emulated screen state rather than the raw byte stream.
//
// Run with: bun run test:e2e:tuistory
// ---------------------------------------------------------------------------
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { launchTerminal, type Session } from "tuistory";
import { afterEach, describe, expect, it } from "vitest";
const cliRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(cliRoot, "src", "index.ts");
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
const LAUNCH_TIMEOUT_MS = 30_000;
const UI_TIMEOUT_MS = 15_000;
const tempDirs: string[] = [];
const sessions: Session[] = [];
function createCliEnv(
overrides: Record<string, string | undefined> = {},
): Record<string, string | undefined> {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-data-"));
const sessionDir = mkdtempSync(
path.join(os.tmpdir(), "cli-tuistory-sessions-"),
);
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
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"),
CLINE_TELEMETRY_DISABLED: "1",
CLINE_NO_AUTO_UPDATE: "1",
// Without this, the ClinePass promo dialog renders over the chat view.
// The stream-grepping interactive suite doesn't notice the overlay, but
// tuistory's screen snapshot reflects what the user actually sees.
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
// The parent vitest process sets CI/VITEST; clear them so the spawned
// CLI renders as a real interactive terminal.
CI: undefined,
VITEST: undefined,
...overrides,
};
}
async function launchCli(
extraArgs: string[] = [],
env: Record<string, string | undefined> = createCliEnv(),
): Promise<Session> {
const session = await launchTerminal({
command: bunExec,
args: [
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
...extraArgs,
],
cwd: cliRoot,
env,
cols: 120,
rows: 36,
// The CLI compiles a large TS graph on cold start; don't gate launch
// on the default 5s first-data timeout.
waitForDataTimeout: LAUNCH_TIMEOUT_MS,
});
sessions.push(session);
return session;
}
/** Wait for the chat view to be fully rendered. */
async function waitForChatView(session: Session): Promise<void> {
await session.waitForText("What can I do for you?", {
timeout: LAUNCH_TIMEOUT_MS,
});
}
describe("cli tuistory e2e", () => {
afterEach(async () => {
for (const session of sessions.splice(0)) {
try {
// Double Ctrl+C exits the TUI cleanly (first press shows the
// "press again to exit" hint) before the PTY is torn down.
await session.press(["ctrl", "c"]);
await session.press(["ctrl", "c"]);
await session.waitIdle({ timeout: 3_000 });
} catch {
// Session may already be dead; close() below still cleans up.
}
session.close();
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("shows the interactive chat view on launch", async () => {
const session = await launchCli();
await waitForChatView(session);
const screen = await session.text({ trimEnd: true });
expect(screen).toContain("What can I do for you?");
expect(screen).toContain("○ Plan ● Act (Tab)");
expect(screen).toContain("Auto-approve all enabled (Shift+Tab)");
});
it("toggles plan/act mode with Tab", async () => {
const session = await launchCli();
await waitForChatView(session);
expect(await session.text()).toContain("○ Plan ● Act (Tab)");
await session.press("tab");
// Reactive wait: resolves as soon as the toggled indicator renders.
await session.waitForText("● Plan ○ Act (Tab)", {
timeout: UI_TIMEOUT_MS,
});
// Unlike stream-grepping, the emulated screen reflects current state:
// the old indicator is gone, not just buried in scrollback.
const screen = await session.text();
expect(screen).toContain("● Plan ○ Act (Tab)");
expect(screen).not.toContain("○ Plan ● Act (Tab)");
});
it("toggles auto-approve-all with Shift+Tab", async () => {
const session = await launchCli();
await waitForChatView(session);
expect(await session.text()).toContain(
"Auto-approve all enabled (Shift+Tab)",
);
await session.press(["shift", "tab"]);
await session.waitForText("Auto-approve all disabled (Shift+Tab)", {
timeout: UI_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).not.toContain("Auto-approve all enabled (Shift+Tab)");
});
it("opens /settings, navigates tabs, and closes with Escape", async () => {
const session = await launchCli();
await waitForChatView(session);
await session.type("/settings");
// Slash menu completion for the settings command.
await session.waitForText("Modify agent configuration", {
timeout: UI_TIMEOUT_MS,
});
// A single Enter accepts the highlighted completion and submits it.
// (The `script`-based suite pressed Enter twice with 250ms sleeps; with
// reactive key delivery the second Enter would leak into the settings
// view and activate the focused row.)
await session.press("enter");
await session.waitForText("←/→ switch tabs", { timeout: UI_TIMEOUT_MS });
const settingsScreen = await session.text();
expect(settingsScreen).toContain("Settings");
expect(settingsScreen).toContain("▸ Provider");
// Switch from the General tab to the MCP tab; the body swaps from the
// provider/model rows to MCP content.
await session.press("right");
await session.text({
waitFor: (text) => !text.includes("Compaction"),
timeout: UI_TIMEOUT_MS,
});
await session.press("escape");
await session.waitForText("Use / for slash commands", {
timeout: UI_TIMEOUT_MS,
});
expect(await session.text()).not.toContain("←/→ switch tabs");
});
it("launches config view directly with `cline config`", async () => {
const session = await launchCli(["config"]);
await session.waitForText("←/→ switch tabs", {
timeout: LAUNCH_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).toContain("Settings");
expect(screen).toContain("▸ Provider");
});
it("dismisses the ClinePass promo with any key and marks it as shown", async () => {
// Re-enable the promo dialog that the shared env suppresses.
const env = createCliEnv({ CLINE_DISABLE_CLINE_PASS_NOTICE: undefined });
const dataDir = env.CLINE_DATA_DIR as string;
const session = await launchCli([], env);
await session.waitForText("Try ClinePass", { timeout: LAUNCH_TIMEOUT_MS });
await session.waitForText("Press Enter to open, any other key to close", {
timeout: UI_TIMEOUT_MS,
});
// Any key other than Enter dismisses the dialog (Esc is unreliable in
// some terminals, notably on Windows).
await session.type("x");
await session.text({
waitFor: (text) => !text.includes("Try ClinePass"),
timeout: UI_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).toContain("What can I do for you?");
expect(screen).not.toContain("Open ClinePass");
// The "shown" marker is persisted once the dialog is dismissed so the
// promo doesn't reappear on the next launch.
const markerPath = path.join(dataDir, "settings", "cli-notices.json");
await session.waitIdle({ timeout: UI_TIMEOUT_MS });
expect(existsSync(markerPath)).toBe(true);
expect(readFileSync(markerPath, "utf8")).toContain(
'"cline-cli-cline-pass-intro": true',
);
});
});
+1 -1
View File
@@ -10,9 +10,9 @@ import {
saveProviderOAuthCredentials,
} from "@cline/core";
import { Command } from "commander";
import open from "open";
import React from "react";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import open from "../utils/open";
import {
getPersistedProviderApiKey,
isOAuthProvider,
+3 -5
View File
@@ -1,11 +1,10 @@
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
type BuiltinToolAvailabilityContext,
createUserInstructionConfigService,
discoverPluginModulePaths,
getPluginDisplayName,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginTools,
@@ -16,7 +15,6 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -211,7 +209,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -271,7 +269,7 @@ async function runPluginsConfigCommand(
continue;
}
pluginsByPath.set(filePath, {
name: getPluginDisplayName(filePath, directory),
name: basename(filePath, extname(filePath)),
path: filePath,
});
}
@@ -1,304 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectIo } from "../connectors/types";
const mocks = vi.hoisted(() => ({
ensureDetachedHubServer: vi.fn(),
readHubDiscovery: vi.fn(),
connect: vi.fn(),
command: vi.fn(),
close: vi.fn(),
clientOptions: vi.fn(),
}));
vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
readHubDiscovery: mocks.readHubDiscovery,
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-production",
discoveryPath: "/tmp/production.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/owner.json",
}),
NodeHubClient: class {
constructor(options: unknown) {
mocks.clientOptions(options);
}
connect = mocks.connect;
command = mocks.command;
close = mocks.close;
},
}));
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
describe("startConnectorViaHub", () => {
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create", "connector.start"],
});
mocks.connect.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
function startRequest(overrides: Record<string, unknown> = {}) {
return {
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
io,
cwd: "/workspace",
...overrides,
};
}
it("hands the start to the hub and reports supervision", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: true,
record: { pid: 4242, state: "running" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 0,
});
expect(mocks.command).toHaveBeenCalledWith("connector.start", {
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
restart: false,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("started under hub supervision pid=4242"),
);
expect(mocks.close).toHaveBeenCalled();
});
it("passes a restart through", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: { started: true, record: { state: "running" } },
});
await startConnectorViaHub(startRequest({ restart: true }));
expect(mocks.command).toHaveBeenCalledWith(
"connector.start",
expect.objectContaining({ restart: true }),
);
});
it("treats an already-running instance as success", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: false,
reason: "already_running",
record: { pid: 99, state: "running" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 0,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("already running under the hub"),
);
});
it("falls back when the hub cannot be reached", async () => {
mocks.ensureDetachedHubServer.mockRejectedValue(new Error("EADDRINUSE"));
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
expect(mocks.command).not.toHaveBeenCalled();
});
it("falls back when a running hub predates connector supervision", async () => {
// The normal state of a long-lived host mid-upgrade: a new CLI, an old hub.
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create"],
});
const outcome = await startConnectorViaHub(startRequest());
expect(outcome).toEqual({
delegated: false,
reason: "hub does not support connector supervision",
});
expect(mocks.command).not.toHaveBeenCalled();
});
it("falls back when the hub reports supervision unavailable", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: false,
error: {
code: "connector_command_failed",
message: "connector supervision is unavailable in this hub",
},
});
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
});
it("falls back when the hub command throws", async () => {
mocks.command.mockRejectedValue(new Error("socket closed"));
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
expect(mocks.close).toHaveBeenCalled();
});
it("surfaces a genuine start refusal instead of starting locally", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: false,
error: {
code: "connector_command_failed",
message: "instanceId is required",
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 1,
});
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining("hub refused to start slack"),
);
});
it("reports a hub that accepted the command but did not start anything", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: false,
record: { state: "failed", lastError: "bad token" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 1,
});
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining("bad token"),
);
});
});
describe("stopConnectorsViaHub", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: [
"connector.start",
"connector.stop",
"connector.supervised",
],
});
mocks.connect.mockResolvedValue(undefined);
});
it("retires every supervised instance of a channel", async () => {
mocks.command.mockImplementation(async (command: string) => {
if (command === "connector.supervised") {
return {
ok: true,
payload: {
supervised: [
{ channel: "slack", instanceId: "a" },
{ channel: "slack", instanceId: "b" },
{ channel: "telegram", instanceId: "c" },
],
},
};
}
return { ok: true, payload: { stopped: true } };
});
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(2);
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "a",
});
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "b",
});
// A different channel is left alone.
expect(mocks.command).not.toHaveBeenCalledWith("connector.stop", {
channel: "telegram",
instanceId: "c",
});
});
it("retires only the requested instance", async () => {
mocks.command.mockImplementation(async (command: string) => {
if (command === "connector.supervised") {
return {
ok: true,
payload: {
supervised: [
{ channel: "slack", instanceId: "a" },
{ channel: "slack", instanceId: "b" },
],
},
};
}
return { ok: true, payload: { stopped: true } };
});
await expect(
stopConnectorsViaHub({ channel: "slack", instanceId: "b" }),
).resolves.toBe(1);
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "b",
});
});
it("reports nothing to stop when the hub supervises none of them", async () => {
mocks.command.mockResolvedValue({ ok: true, payload: { supervised: [] } });
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(0);
});
it("returns undefined when the hub cannot supervise", async () => {
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create"],
});
await expect(
stopConnectorsViaHub({ channel: "slack" }),
).resolves.toBeUndefined();
});
});
-289
View File
@@ -1,289 +0,0 @@
import {
ensureDetachedHubServer,
NodeHubClient,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "@cline/core";
import {
type ConnectorStartResult,
resolveClineBuildEnv,
type SupervisedConnectorRecord,
} from "@cline/shared";
import type { ConnectIo } from "../connectors/types";
/**
* Error codes that mean "this hub cannot supervise connectors", as opposed to
* "the start failed". Both are answers from the hub, but only the former should
* send the caller back to starting the connector itself.
*/
const UNSUPPORTED_ERROR_CODES = new Set([
"unsupported_command",
"unsupported_connector_command",
]);
const UNSUPPORTED_MESSAGE_FRAGMENT = "connector supervision is unavailable";
export type HubDelegationOutcome =
/** The hub owns the connector now; `exitCode` is the command's result. */
| { delegated: true; exitCode: number }
/** Nothing was started; the caller should start the connector locally. */
| { delegated: false; reason: string };
function resolveHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
/**
* Whether the hub at `url` advertises connector supervision.
*
* A newer CLI regularly talks to an older running hub — that is the normal state
* of a long-lived host mid-upgrade — and such a hub would reject
* `connector.start` outright. Checking the advertised capability first keeps that
* case on the local path instead of turning it into a failed start.
*/
async function hubSupportsSupervision(): Promise<boolean> {
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
return record?.capabilities?.includes("connector.start") === true;
} catch {
return false;
}
}
function describeRecord(record: SupervisedConnectorRecord | undefined): string {
if (!record) {
return "";
}
const details = [
record.pid === undefined ? undefined : `pid=${record.pid}`,
`state=${record.state}`,
].filter(Boolean);
return details.length > 0 ? ` ${details.join(" ")}` : "";
}
/**
* What the running hub is supervising, or undefined when it cannot say.
*
* Deliberately does not start a hub: this exists for diagnostics, and `cline
* doctor` reporting on the system must never change it.
*/
export async function listSupervisedConnectorsViaHub(): Promise<
SupervisedConnectorRecord[] | undefined
> {
let url: string;
let authToken: string | undefined;
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (
!record?.url ||
!record.capabilities?.includes("connector.supervised")
) {
return undefined;
}
url = record.url;
authToken = record.authToken;
} catch {
return undefined;
}
const client = new NodeHubClient({
url,
...(authToken ? { authToken } : {}),
clientType: "cli-doctor",
displayName: "doctor",
});
try {
await client.connect();
const reply = await client.command("connector.supervised");
if (!reply.ok) {
return undefined;
}
const supervised = (reply.payload as { supervised?: unknown })?.supervised;
return Array.isArray(supervised)
? (supervised as SupervisedConnectorRecord[])
: undefined;
} catch {
return undefined;
} finally {
try {
client.close();
} catch {
// One-shot connection; a failed close changes nothing.
}
}
}
/**
* Ask the hub to stop supervising a channel's connectors, or one instance of it.
*
* Returns how many the hub stopped, or undefined when it cannot supervise. The
* local stop path alone is not enough: it finds processes through their state
* files, so a connector that has not written one yet — still starting, or failing
* to start — would keep running under the hub and be restarted.
*/
export async function stopConnectorsViaHub(input: {
channel: string;
instanceId?: string;
}): Promise<number | undefined> {
const supervised = await listSupervisedConnectorsViaHub();
if (!supervised) {
return undefined;
}
const targets = supervised.filter(
(record) =>
record.channel === input.channel &&
(input.instanceId === undefined ||
record.instanceId === input.instanceId),
);
if (targets.length === 0) {
return 0;
}
let url: string;
let authToken: string | undefined;
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (!record?.url) {
return undefined;
}
url = record.url;
authToken = record.authToken;
} catch {
return undefined;
}
const client = new NodeHubClient({
url,
...(authToken ? { authToken } : {}),
clientType: "cli-connect",
displayName: `stop ${input.channel}`,
});
let stopped = 0;
try {
await client.connect();
for (const target of targets) {
const reply = await client.command("connector.stop", {
channel: target.channel,
instanceId: target.instanceId,
});
if (reply.ok) {
stopped += 1;
}
}
return stopped;
} catch {
return stopped > 0 ? stopped : undefined;
} finally {
try {
client.close();
} catch {
// One-shot connection; a failed close changes nothing.
}
}
}
/**
* Ask the hub to start and own a connector.
*
* The hub spawning the connector — rather than the connector spawning itself and
* then bringing up a hub — is what makes the hub the single authority on how many
* processes hold one connector's credentials, and what lets it reap and restart
* them when they die. Every failure mode here falls back to the local path so a
* missing or older hub cannot stop a connector from starting.
*/
export async function startConnectorViaHub(input: {
channel: string;
instanceId: string;
args: string[];
restart?: boolean;
io: ConnectIo;
cwd?: string;
}): Promise<HubDelegationOutcome> {
const cwd = input.cwd ?? process.cwd();
let hub: { url: string; authToken: string };
try {
hub = await ensureDetachedHubServer(cwd);
} catch (error) {
return {
delegated: false,
reason: `hub unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
if (!(await hubSupportsSupervision())) {
return {
delegated: false,
reason: "hub does not support connector supervision",
};
}
const client = new NodeHubClient({
url: hub.url,
authToken: hub.authToken,
clientType: "cli-connect",
displayName: `connect ${input.channel}`,
cwd,
});
try {
await client.connect();
const reply = await client.command("connector.start", {
channel: input.channel,
instanceId: input.instanceId,
args: input.args,
restart: input.restart === true,
});
if (!reply.ok) {
const code = reply.error?.code ?? "";
const message = reply.error?.message ?? "connector start failed";
if (
UNSUPPORTED_ERROR_CODES.has(code) ||
message.includes(UNSUPPORTED_MESSAGE_FRAGMENT)
) {
return { delegated: false, reason: message };
}
input.io.writeErr(
`[connect] hub refused to start ${input.channel}: ${message}`,
);
return { delegated: true, exitCode: 1 };
}
const payload = reply.payload as ConnectorStartResult | undefined;
const record = payload?.record;
if (payload?.started === false && payload.reason === "already_running") {
input.io.writeln(
`[connect] ${input.channel} connector ${input.instanceId} is already running under the hub${describeRecord(record)}`,
);
return { delegated: true, exitCode: 0 };
}
if (payload?.started !== true) {
input.io.writeErr(
`[connect] hub could not start ${input.channel} connector ${input.instanceId}${
record?.lastError ? `: ${record.lastError}` : ""
}`,
);
return { delegated: true, exitCode: 1 };
}
input.io.writeln(
`[connect] ${input.channel} connector ${input.instanceId} started under hub supervision${describeRecord(record)}`,
);
input.io.writeln(
"[connect] the hub will restart it if it exits; use `cline connect --stop` to retire it",
);
return { delegated: true, exitCode: 0 };
} catch (error) {
return {
delegated: false,
reason: `hub command failed: ${
error instanceof Error ? error.message : String(error)
}`,
};
} finally {
try {
client.close();
} catch {
// The connection is one-shot; a failed close changes nothing.
}
}
}
-696
View File
@@ -1,696 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
import {
runCleanupConnectorInstance,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
stopAllConnectors,
} from "./connect";
const mocks = vi.hoisted(() => ({
startConnectorViaHub: vi.fn(),
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getProcessStartToken: vi.fn(() => undefined),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
persistConnectorConnection: vi.fn(),
removePersistedConnectorConnection: vi.fn(),
run: vi.fn(),
validate: vi.fn(),
}));
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
getProcessStartToken: mocks.getProcessStartToken,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
}));
vi.mock("../connectors/registry", () => ({
getConnector: mocks.getConnector,
listConnectors: mocks.listConnectors,
}));
vi.mock("./connect-via-hub", () => ({
startConnectorViaHub: mocks.startConnectorViaHub,
stopConnectorsViaHub: mocks.stopConnectorsViaHub,
}));
describe("runConnectAdapter", () => {
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.validate.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
});
afterEach(() => {
if (previousDetachedChild === undefined) {
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
} else {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
}
});
it("persists a successful detached connector start", async () => {
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "token"],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists a successful env-only connector start", async () => {
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
[],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists connector-resolved launch arguments", async () => {
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("resolved_bot");
context.setPersistenceArgs([
"--bot-token",
"token",
"--bot-username",
"resolved_bot",
]);
return 0;
},
);
await expect(
runConnectAdapter("telegram", ["--bot-token", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"resolved_bot",
["--bot-token", "token", "--bot-username", "resolved_bot"],
);
});
it("does not rewrite persistence when a connector is already running", async () => {
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it.each([
"-i",
"--interactive",
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
await expect(
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
"telegram",
"cline_bot",
);
});
it("does not change persistence after a failed foreground run", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist a failed detached launch", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves persistence unchanged when an internal detached child exits", async () => {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist help invocations", async () => {
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves autostart unchanged during shared process cleanup", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(stopAllConnectors(io)).resolves.toEqual({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
executed: 1,
});
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("disables autostart for an explicit stop-all command", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(runStopAllConnectors(io)).resolves.toBe(0);
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
});
it("validates a replacement before stopping the active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.validate.mockResolvedValue(1);
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "bad-token"], io),
).resolves.toBe(1);
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
expect(stopInstance).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
it("shows restart help without stopping an active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
expect(mocks.validate).not.toHaveBeenCalled();
expect(stopInstance).not.toHaveBeenCalled();
});
it("restores the last successful launch when a replacement fails", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run
.mockResolvedValueOnce(1)
.mockImplementationOnce(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenNthCalledWith(
1,
["-k", "new-token"],
io,
expect.any(Object),
);
expect(mocks.run).toHaveBeenNthCalledWith(
2,
["-k", "old-token"],
io,
expect.any(Object),
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "old-token"],
);
});
it("restarts an active instance without persisted rollback arguments", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenCalledWith(
["-k", "new-token"],
io,
expect.any(Object),
);
});
it("does not start a replacement when the active process cannot be stopped", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).not.toHaveBeenCalled();
});
it("does not count an already-running instance as a successful replacement", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(mocks.run).toHaveBeenCalledTimes(1);
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] replacement was not started because telegram instance cline_bot is still running",
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
});
});
describe("runCleanupConnectorInstance", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("reaps one instance without disabling its autostart", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline-slack", io);
// The instance crashed; it was not retired. Disabling autostart here would
// make every crash silently opt the connector out of supervision.
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("reports a failed reap", async () => {
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance: vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
}),
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(1);
});
it("rejects an adapter without per-instance stop", async () => {
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
'connect adapter "slack" does not support per-instance stop',
);
});
it("rejects an unknown adapter", async () => {
mocks.getConnector.mockResolvedValue(undefined);
await expect(
runCleanupConnectorInstance("nope", "instance", io),
).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith('unknown connect adapter "nope"');
});
});
describe("hub-delegated connector starts", () => {
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.validate.mockResolvedValue(0);
mocks.run.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
resolveInstanceId: () => "cline-slack",
});
mocks.startConnectorViaHub.mockResolvedValue({
delegated: true,
exitCode: 0,
});
});
afterEach(() => {
delete process.env.CLINE_CONNECTOR_SUPERVISED;
});
it("asks the hub to own a background connector and records the intent", async () => {
await expect(
runConnectAdapter("slack", ["--bot-token", "xoxb"], io),
).resolves.toBe(0);
expect(mocks.startConnectorViaHub).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
}),
);
// The adapter must not also run here: the hub owns the process now.
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"slack",
"cline-slack",
["--bot-token", "xoxb"],
);
});
it("runs locally for a foreground connector", async () => {
await runConnectAdapter("slack", ["--bot-token", "xoxb", "-i"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("runs locally inside a supervised process instead of asking the hub again", async () => {
process.env.CLINE_CONNECTOR_SUPERVISED = "1";
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
// Delegating here would send the hub straight back to spawning this same
// process.
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("runs locally when the instance id cannot be known up front", async () => {
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
resolveInstanceId: () => undefined,
});
await runConnectAdapter("telegram", ["-k", "token"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("falls back to a local start when the hub declines", async () => {
mocks.startConnectorViaHub.mockResolvedValue({
delegated: false,
reason: "hub does not support connector supervision",
});
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
expect(mocks.run).toHaveBeenCalled();
});
it("does not validate or delegate a help invocation", async () => {
await runConnectAdapter("slack", ["--help"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.validate).not.toHaveBeenCalled();
});
it("reports a validation failure without contacting the hub", async () => {
mocks.validate.mockResolvedValue(2);
await expect(
runConnectAdapter("slack", ["--bot-token", "bad"], io),
).resolves.toBe(2);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
});
+13 -343
View File
@@ -1,34 +1,10 @@
import {
disableConnectorAutostart,
getPersistedConnectorConnection,
listActiveConnectors,
persistConnectorConnection,
removePersistedConnectorConnection,
} from "@cline/core";
import {
isSupervisedConnectorProcess,
setStartingConnectorInstance,
} from "@cline/shared";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import { getConnector, listConnectors } from "../connectors/registry";
import type {
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
export async function stopAllConnectors(
io: ConnectIo,
): Promise<ConnectStopResult & { executed: number }> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
let executed = 0;
for (const entry of listConnectors()) {
@@ -42,339 +18,42 @@ export async function stopAllConnectors(
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
return { stoppedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
const { stoppedProcesses, stoppedSessions, executed } =
await stopAllConnectors(io);
if (executed === 0) {
io.writeln("[connect] no adapters support stop yet");
return 0;
}
disableConnectorAutostart();
io.writeln(
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
);
return failedProcesses === 0 ? 0 : 1;
return 0;
}
export async function runStopConnector(
adapterName: string,
io: ConnectIo,
options: {
autostart: "disable" | "preserve";
instanceId?: string;
} = {
autostart: "disable",
},
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const stop = options.instanceId
? connector.stopInstance
? () => connector.stopInstance?.(options.instanceId ?? "", io)
: undefined
: connector.stopAll
? () => connector.stopAll?.(io)
: undefined;
if (!stop) {
if (!connector.stopAll) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
// Retire it with the hub first. The local stop below finds processes through
// their state files, so a supervised connector that has not written one yet
// would survive and be restarted.
const stoppedByHub = await stopConnectorsViaHub({
channel: connector.name,
...(options.instanceId === undefined
? {}
: { instanceId: options.instanceId }),
});
if (stoppedByHub) {
io.writeln(
`[connect] hub stopped supervising ${stoppedByHub} ${connector.name} connector${stoppedByHub === 1 ? "" : "s"}`,
);
}
const result = await stop();
if (!result) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
if (options.autostart === "disable") {
disableConnectorAutostart(connector.name, options.instanceId);
}
const result: ConnectStopResult = await connector.stopAll(io);
io.writeln(
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
);
return result.failedProcesses === 0 ? 0 : 1;
}
/**
* Reap one connector instance that is no longer running.
*
* Invoked by the hub supervisor when it observes a connector die. It clears the
* same things a normal stop does — process state file, thread→session bindings,
* the instance's hub sessions — but deliberately leaves the autostart record
* intact: the instance crashed, it was not retired, so the supervisor still
* intends to restart it. `runStopConnector` with `autostart: "disable"` would
* make every crash silently opt the connector out of recovery.
*/
export async function runCleanupConnectorInstance(
adapterName: string,
instanceId: string,
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
if (!connector.stopInstance) {
io.writeErr(
`connect adapter "${adapterName}" does not support per-instance stop`,
);
return 1;
}
const result = await connector.stopInstance(instanceId, io);
io.writeln(
`[connect] ${connector.name} instance=${instanceId} cleaned processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
);
return result.failedProcesses === 0 ? 0 : 1;
}
export async function runRestartConnector(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
requestedInstanceId?: string,
): Promise<number> {
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const activeInstances = listActiveConnectors().filter(
(record) => record.type === adapterName,
);
if (!requestedInstanceId && activeInstances.length > 1) {
io.writeErr(
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
);
return 1;
}
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
const targetIsActive =
instanceId !== undefined &&
activeInstances.some((record) => record.instanceId === instanceId);
if (!targetIsActive || !instanceId) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
// The supervisor replaces an instance in one step, so let it do the whole
// restart rather than stopping here and racing it to start the replacement.
// Only when the target is the instance these arguments describe: a
// `--restart-instance` pointing elsewhere is not ours to reinterpret.
if (
requestedInstanceId === undefined ||
connector.resolveInstanceId?.(passthroughArgs) === requestedInstanceId
) {
const delegated = await tryDelegateToHub(connector, passthroughArgs, io, {
restart: true,
});
if (delegated !== undefined) {
return delegated;
}
}
const previousConnection = getPersistedConnectorConnection(
adapterName,
instanceId,
);
const stopExitCode = await runStopConnector(adapterName, io, {
autostart: "preserve",
instanceId,
});
if (stopExitCode !== 0) {
return stopExitCode;
}
const replacement = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
if (replacement.exitCode === 0) {
if (replacement.instanceId && replacement.instanceId !== instanceId) {
removePersistedConnectorConnection(adapterName, instanceId);
}
return 0;
}
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
io.writeErr(
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
);
return 1;
}
if (!previousConnection) {
io.writeErr(
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
);
return replacement.exitCode;
}
io.writeErr(
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
);
const rollback = await runConnectAdapterWithResult(
adapterName,
previousConnection.lastSuccessfulArgs,
io,
);
if (rollback.exitCode === 0) {
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
} else {
io.writeErr(
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
);
}
return replacement.exitCode;
}
interface ConnectAdapterResult {
exitCode: number;
instanceId?: string;
}
async function runConnectAdapterWithResult(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<ConnectAdapterResult> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return { exitCode: 1 };
}
let persistenceArgs = passthroughArgs;
let persistenceInstanceId: string | undefined;
const context: ConnectRunContext = {
setPersistenceArgs: (args) => {
persistenceArgs = [...args];
},
setPersistenceInstanceId: (instanceId) => {
persistenceInstanceId = instanceId;
// Adapters report their instance id before they build a Cline core, so
// this lands in the environment before the hub daemon is spawned and
// inherited by it. Without it the daemon's autostart pass cannot tell
// that this instance is mid-startup and launches a second copy of it.
setStartingConnectorInstance({
channel: connector.name,
instanceId,
});
},
};
const exitCode = await connector.run(passthroughArgs, io, context);
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
return { exitCode, instanceId: persistenceInstanceId };
}
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
const isInteractiveInvocation = passthroughArgs.some((arg) =>
INTERACTIVE_FLAGS.has(arg),
);
// A supervised process is the hub's own connector, not a user invocation, so
// it makes the same autostart bookkeeping choices as a detached child: the
// process that asked for the start already recorded the intent.
const isDetachedChild =
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
isSupervisedConnectorProcess();
if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
isInteractiveInvocation
) {
disableConnectorAutostart(connector.name, persistenceInstanceId);
} else if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
persistenceInstanceId
) {
persistConnectorConnection(
connector.name,
persistenceInstanceId,
persistenceArgs,
);
}
return { exitCode, instanceId: persistenceInstanceId };
}
/**
* Hand a background connector start to the hub, when that is possible.
*
* Returns the exit code once the hub owns the connector, or undefined to mean
* "start it locally instead". Delegation is skipped for foreground (`-i`) runs,
* which are attached to the user's terminal, and for connectors the hub itself
* launched, which would otherwise ask the hub to start them again.
*/
async function tryDelegateToHub(
connector: {
name: string;
validate: (args: string[], io: ConnectIo) => Promise<number>;
resolveInstanceId?: (args: string[]) => string | undefined;
},
passthroughArgs: string[],
io: ConnectIo,
options: { restart?: boolean } = {},
): Promise<number | undefined> {
if (
passthroughArgs.some((arg) => HELP_FLAGS.has(arg)) ||
passthroughArgs.some((arg) => INTERACTIVE_FLAGS.has(arg)) ||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
isSupervisedConnectorProcess()
) {
return undefined;
}
// Without an instance id the hub cannot enforce one process per connector,
// which is the entire point of routing through it.
const instanceId = connector.resolveInstanceId?.(passthroughArgs);
if (!instanceId) {
return undefined;
}
// Check the arguments here rather than after handing off: a bad token should
// fail in front of the user instead of becoming a supervised crash loop.
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
const outcome = await startConnectorViaHub({
channel: connector.name,
instanceId,
args: passthroughArgs,
...(options.restart === undefined ? {} : { restart: options.restart }),
io,
});
if (!outcome.delegated) {
return undefined;
}
if (outcome.exitCode === 0) {
// Recorded here rather than in the hub-spawned process: this is the
// invocation that expressed the intent to keep the connector running.
persistConnectorConnection(connector.name, instanceId, passthroughArgs);
}
return outcome.exitCode;
return 0;
}
export async function runConnectAdapter(
@@ -383,20 +62,11 @@ export async function runConnectAdapter(
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (connector) {
const delegated = await tryDelegateToHub(connector, passthroughArgs, io);
if (delegated !== undefined) {
return delegated;
}
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
return connector.run(passthroughArgs, io);
}
export function formatAdapterList(): string {
+1 -1
View File
@@ -2,8 +2,8 @@ import { existsSync } from "node:fs";
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 open from "../utils/open";
import { c } from "../utils/output";
export interface DashboardServerHandle {
+1 -317
View File
@@ -9,7 +9,6 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -18,14 +17,11 @@ const {
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockReadSupersededHubDiscovery,
mockProbeHubServer,
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
mockListSupervisedConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
@@ -49,19 +45,15 @@ const {
),
})),
mockReadHubDiscovery: vi.fn(),
mockReadSupersededHubDiscovery: vi.fn(() => undefined as unknown),
mockProbeHubServer: vi.fn(),
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
mockEnsureFileExists: vi.fn(),
mockListActiveConnectors: vi.fn(() => []),
mockStopAllConnectors: vi.fn(async () => ({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
})),
mockListSupervisedConnectors: vi.fn(async () => undefined as unknown),
}));
vi.mock("node:child_process", () => ({
@@ -75,10 +67,8 @@ vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
@@ -89,11 +79,7 @@ vi.mock("./connect", () => ({
stopAllConnectors: mockStopAllConnectors,
}));
vi.mock("./connect-via-hub", () => ({
listSupervisedConnectorsViaHub: mockListSupervisedConnectors,
}));
import { __test__, createDoctorCommand, runDoctorCommand } from "./doctor";
import { createDoctorCommand, runDoctorCommand } from "./doctor";
describe("runDoctorCommand", () => {
const tempDirs: string[] = [];
@@ -113,7 +99,6 @@ describe("runDoctorCommand", () => {
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
@@ -189,99 +174,6 @@ describe("runDoctorCommand", () => {
);
});
it("sees the hub through the set-aside record during the shielded update window", async () => {
const cwd = "/workspace";
// The npm postinstall shield renamed the discovery record aside; the
// hub is alive and serving an old client's sessions.
mockReadHubDiscovery.mockResolvedValue(undefined);
mockReadSupersededHubDiscovery.mockReturnValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "shielded-token",
pid: 50174,
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
});
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
if (command === "lsof") {
return { status: 0, stdout: "50174\n" };
}
if (
command === "pgrep" &&
Array.isArray(args) &&
args[2] === "--cline-hub-daemon"
) {
return {
status: 0,
stdout: "50174 /usr/local/bin/cline --cline-hub-daemon\n",
};
}
return { status: 1, stdout: "" };
});
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(mockProbeHubServer).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
{
authToken: "shielded-token",
},
);
// Without the fallback the live daemon reads as stale and doctor's
// advice (\"run doctor fix\") would kill the sessions the shield exists
// to protect.
expect(JSON.parse(output[0] || "")).toMatchObject({
hubHealthy: true,
staleHubPids: [],
});
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
@@ -356,7 +248,6 @@ describe("runDoctorCommand", () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
@@ -518,210 +409,3 @@ describe("createDoctorCommand log subcommand", () => {
expect(errors[0]).toContain("open failed");
});
});
describe("container-aware process filtering", () => {
const { decideForeignContainer, CONTAINER_CGROUP_PATTERN } = __test__;
it("treats a process in a different pid namespace as foreign", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [
["pid:[4026531836]", "pid:[4026532500]"],
[undefined, undefined],
],
ownContainerId: undefined,
otherContainerId: undefined,
}),
).toBe(true);
});
it("keeps a sibling process in our own namespaces", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [
["pid:[4026531836]", "pid:[4026531836]"],
["mnt:[4026531840]", "mnt:[4026531840]"],
],
ownContainerId: undefined,
otherContainerId: undefined,
}),
).toBe(false);
});
it("falls back to cgroup container ids when namespaces are unreadable", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [[undefined, undefined]],
ownContainerId: undefined,
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
}),
).toBe(true);
// Same container: our own sibling process, not something to retire.
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [[undefined, undefined]],
ownContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
}),
).toBe(false);
});
it("never filters off Linux, where containers cannot share our pid space", () => {
expect(
decideForeignContainer({
platform: "darwin",
namespacePairs: [["pid:[1]", "pid:[2]"]],
ownContainerId: undefined,
otherContainerId: "abcdef123456",
}),
).toBe(false);
});
it("extracts container ids from real cgroup paths", () => {
const docker =
"0::/system.slice/docker-7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad.scope";
expect(docker.match(CONTAINER_CGROUP_PATTERN)?.[1]).toBe(
"7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad",
);
// A plain host session must not look like a container.
expect(
"0::/user.slice/user-1001.slice/session-121.scope".match(
CONTAINER_CGROUP_PATTERN,
),
).toBeNull();
});
});
describe("doctor supervision reporting", () => {
const { formatSupervisedConnector } = __test__;
afterEach(() => {
vi.clearAllMocks();
mockListSupervisedConnectors.mockResolvedValue(undefined);
});
async function runDoctorJson(): Promise<Record<string, unknown>> {
const output: string[] = [];
await runDoctorCommand(
{ cwd: "/workspace", json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
return JSON.parse(output[0] || "{}") as Record<string, unknown>;
}
it("reports what the hub is supervising", async () => {
mockListSupervisedConnectors.mockResolvedValue([
{
channel: "slack",
instanceId: "cline-slack",
state: "backoff",
origin: "spawned",
restarts: 3,
},
]);
await expect(runDoctorJson()).resolves.toMatchObject({
supervisedConnectors: [
{ channel: "slack", instanceId: "cline-slack", state: "backoff" },
],
});
});
it("omits supervision when the hub cannot report it", async () => {
mockListSupervisedConnectors.mockResolvedValue(undefined);
const status = await runDoctorJson();
expect(status.supervisedConnectors).toBeUndefined();
});
it("stays usable when the supervision query fails", async () => {
mockListSupervisedConnectors.mockRejectedValue(new Error("hub gone"));
// Diagnostics must degrade quietly rather than fail.
const status = await runDoctorJson();
expect(status.supervisedConnectors).toBeUndefined();
expect(status).toHaveProperty("hubHealthy");
});
it("formats restart and failure state so a crash loop is visible", () => {
expect(
formatSupervisedConnector({
channel: "slack",
instanceId: "cline-slack",
state: "failed",
origin: "adopted",
pid: 42,
restarts: 5,
lastExitCode: 1,
lastError: "invalid token",
}),
).toBe(
"slack | instance=cline-slack | state=failed | origin=adopted | pid=42 | restarts=5 | lastExit=1 | error=invalid token",
);
});
it("leaves out fields that do not apply to a healthy connector", () => {
expect(
formatSupervisedConnector({
channel: "telegram",
instanceId: "cline_bot",
state: "running",
origin: "spawned",
pid: 7,
restarts: 0,
}),
).toBe(
"telegram | instance=cline_bot | state=running | origin=spawned | pid=7",
);
});
});
describe("describeProcessesStartedDuringFix", () => {
const { describeProcessesStartedDuringFix } = __test__;
const liveParents = new Map([
[100, 10],
[200, 20],
]);
const resolveLiveParent = (pid: number) => liveParents.get(pid);
it("says nothing when no process started during the fix", () => {
expect(
describeProcessesStartedDuringFix([], resolveLiveParent),
).toBeUndefined();
});
it("blames the parent only when every process has a live one", () => {
expect(
describeProcessesStartedDuringFix([100, 200], resolveLiveParent),
).toBe(
"\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.",
);
});
// A process can start on its own mid-repair - a user opening a new session,
// say - and telling them to go kill an unrelated parent would be wrong.
it("states the facts when no process has a live parent", () => {
expect(describeProcessesStartedDuringFix([777], resolveLiveParent)).toBe(
"\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.",
);
});
it("separates respawns from independent starts in a mixed batch", () => {
expect(
describeProcessesStartedDuringFix([100, 777], resolveLiveParent),
).toBe(
"\nSome of these were respawned by a live parent (100); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.",
);
});
});
+22 -294
View File
@@ -1,32 +1,27 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readlinkSync, rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
listActiveConnectors,
probeHubServer,
readHubDiscovery,
readSupersededHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
type SupervisedConnectorRecord,
} from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
listActiveConnectors,
} from "../connectors/status";
import { getCliBuildInfo } from "../utils/common";
import open from "../utils/open";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
import { listSupervisedConnectorsViaHub } from "./connect-via-hub";
type DoctorIo = {
writeln: (text?: string) => void;
@@ -54,8 +49,6 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -67,8 +60,6 @@ type DoctorStatus = {
staleCliPids: number[];
staleSidecarPids: number[];
activeConnectors: ActiveConnectorRecord[];
/** Undefined when the running hub cannot report supervision. */
supervisedConnectors?: SupervisedConnectorRecord[];
recentSpawnedProcesses: SpawnedProcessRecord[];
};
@@ -77,13 +68,6 @@ type ProcessRecord = {
command: string;
};
// Container id inside a cgroup path, e.g.
// "0::/system.slice/docker-<64-hex>.scope" (docker/containerd/podman) or
// "/kubepods/.../<64-hex>" (kubernetes). Captures the id so two different
// containers can be told apart, not merely "is containerised".
const CONTAINER_CGROUP_PATTERN =
/(?:docker[-/]|containerd[-/]|libpod[-/]|crio[-/]|lxc[-/.])([0-9a-f]{12,64})/;
function parsePids(raw: string): number[] {
return raw
.split(/\r?\n/)
@@ -91,77 +75,6 @@ function parsePids(raw: string): number[] {
.filter((pid) => Number.isInteger(pid) && pid > 0);
}
function tryReadLink(target: string): string | undefined {
try {
return readlinkSync(target);
} catch {
return undefined;
}
}
function readContainerCgroupId(pid: number | "self"): string | undefined {
let raw: string;
try {
raw = readFileSync(`/proc/${pid}/cgroup`, "utf8");
} catch {
return undefined;
}
return raw.match(CONTAINER_CGROUP_PATTERN)?.[1];
}
/**
* Decide whether a process belongs to a container other than our own.
*
* Namespace identity is the reliable signal: a containerised process has
* different PID/mount namespaces than the host process running the scan.
* Container ids parsed from cgroup paths are the fallback for kernels where the
* namespace links are unreadable. Unknown on both sides means "assume ours",
* preserving the previous behaviour rather than silently dropping processes the
* user does want cleaned up.
*/
function decideForeignContainer(input: {
platform: string;
namespacePairs: Array<[string | undefined, string | undefined]>;
ownContainerId: string | undefined;
otherContainerId: string | undefined;
}): boolean {
// /proc/<pid>/ns exists only on Linux. Elsewhere containers run inside a VM
// and never share a pid space with us, so there is nothing to disambiguate.
if (input.platform !== "linux") {
return false;
}
for (const [own, other] of input.namespacePairs) {
if (own && other && own !== other) {
return true;
}
}
return (
Boolean(input.otherContainerId) &&
input.otherContainerId !== input.ownContainerId
);
}
/**
* True when `pid` belongs to a container other than this process's own.
*
* `pgrep` sees every process on the host, containers included: a Docker agent's
* hub daemon shows up beside ours, and when the container shares our uid `kill`
* on it succeeds. Those daemons are emphatically not stale — they belong to a
* live agent with its own data dir — so reporting them, and killing them in
* `doctor fix`, takes down an unrelated agent.
*/
function isForeignContainerPid(pid: number): boolean {
return decideForeignContainer({
platform: process.platform,
namespacePairs: (["pid", "mnt"] as const).map((namespace) => [
tryReadLink(`/proc/self/ns/${namespace}`),
tryReadLink(`/proc/${pid}/ns/${namespace}`),
]),
ownContainerId: readContainerCgroupId("self"),
otherContainerId: readContainerCgroupId(pid),
});
}
function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
@@ -191,8 +104,7 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
pid <= 0 ||
!command ||
pid === process.pid ||
pid === process.ppid ||
isForeignContainerPid(pid)
pid === process.ppid
) {
continue;
}
@@ -385,12 +297,6 @@ async function clearHubStartupArtifacts(
await clearHubDiscovery(owner.discoveryPath);
clearedDiscovery = 1;
}
if (options?.clearDiscovery) {
// The set-aside copy the npm postinstall shield leaves behind. Once
// doctor has deliberately stopped everything, keeping it risks a much
// later launch SIGTERMing whatever process has recycled its pid.
clearPathIfExists(`${owner.discoveryPath}.superseded`);
}
return {
startupLocks: clearedStartupLocks,
discovery: clearedDiscovery,
@@ -418,25 +324,7 @@ function resolveCliHubOwnerContext() {
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveCliHubOwnerContext();
// The npm postinstall shield sets the discovery record aside (see
// readSupersededHubDiscovery) while an older hub finishes serving its
// sessions. Without the fallback, doctor cannot see that hub, classifies
// the live daemon as stale, and its "run doctor fix" advice kills the
// sessions the shield exists to protect.
const recorded = await readHubDiscovery(owner.discoveryPath);
// The set-aside record carries only url/token/pid; widen so the two
// sources read uniformly below.
const discovery:
| {
url?: string;
authToken?: string;
pid?: number;
port?: number;
coreVersion?: string;
}
| undefined = recorded?.url
? recorded
: readSupersededHubDiscovery(owner.discoveryPath);
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
: undefined;
@@ -449,8 +337,6 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -462,7 +348,6 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
activeConnectors: listActiveConnectors(),
...((await listSupervisedConnectorsSafely()) ?? {}),
recentSpawnedProcesses: readRecentSpawnedProcesses(),
};
}
@@ -474,80 +359,6 @@ function formatPidList(label: string, pids: number[]): string {
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
}
function readParentPid(pid: number): number | undefined {
try {
const output = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
encoding: "utf8",
});
const parsed = Number(output.stdout?.trim());
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
} catch {
return undefined;
}
}
function liveParentPid(pid: number): number | undefined {
const parent = readParentPid(pid);
return parent && isProcessRunning(parent) ? parent : undefined;
}
/**
* A daemon whose parent is still running was almost certainly just spawned by
* that parent, and killing it only invites the parent to spawn another. Naming
* the parent points at the process the user actually has to stop.
*/
function formatDaemonPidList(label: string, pids: number[]): string {
if (pids.length === 0) {
return `${label} ${c.dim}0${c.reset}`;
}
const described = pids.map((pid) => {
const parent = liveParentPid(pid);
return parent ? `${pid} (spawned by ${parent})` : String(pid);
});
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
}
/**
* Advice for processes first seen during the fix. Only a process with a live
* parent is known to have been respawned by it; anything else may have been
* started independently (a user opening a new session mid-repair), so it gets
* a statement of fact rather than an instruction to go kill something.
*/
export function describeProcessesStartedDuringFix(
pids: number[],
resolveLiveParent: (pid: number) => number | undefined,
): string | undefined {
if (pids.length === 0) {
return undefined;
}
const respawned = pids.filter((pid) => resolveLiveParent(pid) !== undefined);
if (respawned.length === 0) {
return "\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.";
}
if (respawned.length === pids.length) {
return "\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.";
}
return `\nSome of these were respawned by a live parent (${respawned.join(", ")}); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.`;
}
function formatStartupLockList(
label: string,
locks: StartupArtifact[],
): string {
const described = locks
.map((lock) => {
if (lock.pid === undefined) {
return "unreadable";
}
return lock.stale ? `${lock.pid} (stale)` : `${lock.pid} (held, live)`;
})
.filter((entry) => entry.length > 0);
if (described.length === 0) {
return `${label} ${c.dim}0${c.reset}`;
}
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
}
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
const pieces = [
record.timestamp ?? "unknown-time",
@@ -561,39 +372,6 @@ function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
return pieces.join(" | ");
}
/**
* Supervision is reported by the running hub, so it is unavailable whenever
* there is no hub or it predates supervision. Diagnostics must degrade quietly
* rather than fail.
*/
async function listSupervisedConnectorsSafely(): Promise<
{ supervisedConnectors: SupervisedConnectorRecord[] } | undefined
> {
try {
const supervised = await listSupervisedConnectorsViaHub();
return supervised ? { supervisedConnectors: supervised } : undefined;
} catch {
return undefined;
}
}
function formatSupervisedConnector(record: SupervisedConnectorRecord): string {
const pieces = [
record.channel,
`instance=${record.instanceId}`,
`state=${record.state}`,
`origin=${record.origin}`,
record.pid === undefined ? undefined : `pid=${record.pid}`,
record.restarts > 0 ? `restarts=${record.restarts}` : undefined,
record.nextRestartAt ? `nextRestart=${record.nextRestartAt}` : undefined,
record.lastExitCode === undefined
? undefined
: `lastExit=${record.lastExitCode}`,
record.lastError ? `error=${record.lastError}` : undefined,
];
return pieces.filter(Boolean).join(" | ");
}
function formatActiveConnector(record: ActiveConnectorRecord): string {
const identity =
record.type === "telegram"
@@ -627,13 +405,6 @@ function killPids(pids: number[]): number {
return killed;
}
export const __test__ = {
decideForeignContainer,
CONTAINER_CGROUP_PATTERN,
describeProcessesStartedDuringFix,
formatSupervisedConnector,
};
export async function runDoctorCommand(
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
io: DoctorIo,
@@ -648,16 +419,19 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatDaemonPidList("stale hub daemons", before.staleHubPids));
writeln(formatStartupLockList("hub startup locks", before.hubStartupLocks));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
),
);
writeln(formatPidList("cli processes", before.staleCliPids));
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
if (before.activeConnectors.length === 0) {
@@ -668,12 +442,6 @@ export async function runDoctorCommand(
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
}
}
if (before.supervisedConnectors?.length) {
writeln("hub-supervised connectors:");
for (const record of before.supervisedConnectors) {
writeln(`- ${c.dim}${formatSupervisedConnector(record)}${c.reset}`);
}
}
if (verbose && before.recentSpawnedProcesses.length > 0) {
writeln("recent spawned processes:");
for (const record of before.recentSpawnedProcesses) {
@@ -768,56 +536,16 @@ export async function runDoctorCommand(
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
// "Remaining" means a process this run tried to kill and failed to. A
// re-scan alone cannot tell that apart from a process that appeared while
// the fix was running, and reporting the two together reads as a failure
// to kill something that was never targeted.
const survived = (targets: number[], remaining: number[]) =>
remaining.filter((pid) => targets.includes(pid));
const appeared = (targets: number[], remaining: number[]) =>
remaining.filter((pid) => !targets.includes(pid));
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub listeners",
survived(refreshedAfterGracefulStop.listeningPids, after.listeningPids),
"remaining hub startup locks",
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
),
);
writeln(
formatDaemonPidList(
"remaining stale hub daemons",
survived(staleHubTargets, after.staleHubPids),
),
);
writeln(
formatStartupLockList("remaining hub startup locks", after.hubStartupLocks),
);
writeln(
formatPidList(
"remaining cli processes",
survived(staleCliTargets, after.staleCliPids),
),
);
writeln(
formatPidList(
"remaining sidecar processes",
survived(staleSidecarTargets, after.staleSidecarPids),
),
);
const spawnedDuringFix = [
...appeared(staleHubTargets, after.staleHubPids),
...appeared(staleCliTargets, after.staleCliPids),
...appeared(staleSidecarTargets, after.staleSidecarPids),
];
if (spawnedDuringFix.length > 0) {
writeln(formatDaemonPidList("started during fix", spawnedDuringFix));
const advice = describeProcessesStartedDuringFix(
spawnedDuringFix,
liveParentPid,
);
if (advice) {
io.writeln(advice);
}
}
writeln(formatPidList("remaining cli processes", after.staleCliPids));
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
return 0;
}
@@ -1,94 +0,0 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const historyMocks = vi.hoisted(() => ({
runHistoryDelete: vi.fn(async () => 0),
runHistoryExport: vi.fn(async () => 0),
runHistoryList: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
vi.mock("./history", () => historyMocks);
import { registerHistoryCommand } from "./history-command";
function createHarness(isInteractiveTTY: boolean) {
const program = new Command()
.exitOverride()
.option("--json", "Output as JSON");
program.configureOutput({
writeOut: vi.fn(),
writeErr: vi.fn(),
});
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const setExitCode = vi.fn();
const setStartupTarget = vi.fn();
registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY: () => isInteractiveTTY,
});
return { program, io, setExitCode, setStartupTarget };
}
describe("registerHistoryCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens the in-app history picker for an interactive text terminal", async () => {
const { program, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history"], { from: "user" });
expect(setStartupTarget).toHaveBeenCalledOnce();
expect(setStartupTarget).toHaveBeenCalledWith("history");
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
expect(setExitCode).not.toHaveBeenCalled();
});
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history", "--json"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 50,
outputMode: "json",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("prints text history when no interactive terminal is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 12,
outputMode: "text",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("returns an error when delete is missing --session-id", async () => {
const { program, io, setExitCode } = createHarness(false);
await program.parseAsync(["history", "delete"], { from: "user" });
expect(io.writeErr).toHaveBeenCalledWith(
"history delete requires --session-id <id>",
);
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
expect(setExitCode).toHaveBeenCalledWith(1);
});
});
-117
View File
@@ -1,117 +0,0 @@
import type { Command } from "commander";
import type { TuiStartupTarget } from "../tui/types";
import type { CliOutputMode } from "../utils/types";
import {
runHistoryDelete,
runHistoryExport,
runHistoryList,
runHistoryUpdate,
} from "./history";
type HistoryCommandIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
type RegisterHistoryCommandOptions = {
program: Command;
io: HistoryCommandIo;
setExitCode: (code: number) => void;
setStartupTarget: (target: TuiStartupTarget) => void;
isInteractiveTTY?: () => boolean;
};
function resolveHistoryOutputMode(
program: Command,
historyCmd: Command,
): CliOutputMode {
return program.opts().json || historyCmd.opts().json ? "json" : "text";
}
export function registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY = () =>
process.stdin.isTTY === true && process.stdout.isTTY === true,
}: RegisterHistoryCommandOptions): void {
const historyCmd = program
.command("history")
.alias("h")
.description("List session history or manage saved sessions")
.option("--json", "Output as JSON")
.option("--limit <count>", "Maximum number of sessions to show", "50")
.option("--page <number>", "Page number for paginated results")
.option("--config <dir>", "configuration directory")
.action(async () => {
const opts = historyCmd.opts();
const limit = Number.parseInt(opts.limit, 10);
const outputMode = resolveHistoryOutputMode(program, historyCmd);
if (outputMode === "text" && isInteractiveTTY()) {
setStartupTarget("history");
return;
}
setExitCode(
await runHistoryList({
limit,
outputMode,
io,
}),
);
});
const historyDeleteCmd = historyCmd
.command("delete")
.description("Delete a session from history")
.option("--session-id <id>", "Session ID to delete")
.action(async () => {
const opts = historyDeleteCmd.opts();
if (!opts.sessionId) {
io.writeErr("history delete requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
});
const historyUpdateCmd = historyCmd
.command("update")
.description("Update a session in history")
.option("--metadata <json>", "Metadata as JSON string")
.option("--prompt <text>", "New prompt text")
.option("--session-id <id>", "Session ID to update")
.option("--title <text>", "New title")
.action(async () => {
const opts = historyUpdateCmd.opts();
if (!opts.sessionId) {
io.writeErr("history update requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryUpdate(
opts.sessionId,
opts.prompt,
opts.title,
opts.metadata,
outputMode,
io,
),
);
});
const historyExportCmd = historyCmd
.command("export <sessionId>")
.description("Export a session as a standalone HTML file")
.option("-o, --output <path>", "Output HTML file path")
.action(async (sessionId: string) => {
const opts = historyExportCmd.opts();
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryExport(sessionId, opts.output, outputMode, io),
);
});
}
+10 -83
View File
@@ -3,7 +3,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionHistoryRecord } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { exportHistorySession } from "../session/history-export";
import {
formatCheckpointDetail,
formatHistoryListLine,
@@ -17,12 +16,18 @@ vi.mock("../session/session", () => ({
readSessionMessagesArtifact: vi.fn(),
}));
vi.mock("../tui/history-standalone", () => ({
renderHistoryStandalone: vi.fn(async () => 0),
}));
import { listSessions, readSessionMessagesArtifact } from "../session/session";
import { renderHistoryStandalone } from "../tui/history-standalone";
const mockedReadSessionMessagesArtifact = vi.mocked(
readSessionMessagesArtifact,
);
const mockedListSessions = vi.mocked(listSessions);
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
function createHistoryRow(
overrides: Partial<SessionHistoryRecord> = {},
@@ -195,11 +200,8 @@ describe("runHistoryList", () => {
vi.clearAllMocks();
});
it("requests hydrated text history rows so titles can come from messages", async () => {
const row = createHistoryRow({
prompt: undefined,
metadata: { title: "hydrated title", totalCost: 0.25 },
});
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
mockedListSessions.mockResolvedValue([row]);
const io = {
writeln: vi.fn(),
@@ -216,8 +218,8 @@ describe("runHistoryList", () => {
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: true,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("hydrated title"),
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
expect.objectContaining({ rows: [row] }),
);
});
@@ -311,81 +313,6 @@ describe("runHistoryExport", () => {
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("writes structured JSON from a persisted messages artifact", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
systemPrompt: "Be helpful",
messages: [
{
id: "m1",
role: "user",
content: [{ type: "text", text: "hello" }],
},
{
id: "m2",
role: "assistant",
content: [{ type: "text", text: "world" }],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const targetPath = await exportHistorySession({
sessionId: "sess_1",
format: "json",
outputDirectory: tempDir,
});
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
await expect(
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
).resolves.toEqual(artifact);
});
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 = {
+46 -14
View File
@@ -1,6 +1,13 @@
import { exportHistorySession } from "../session/history-export";
import { deleteSession, listSessions, updateSession } from "../session/session";
import { formatHistoryListLine } from "../utils/history-format";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "../session/export";
import {
deleteSession,
listSessions,
readSessionMessagesArtifact,
updateSession,
} from "../session/session";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import { writeln } from "../utils/output";
import type { CliOutputMode } from "../utils/types";
@@ -15,6 +22,22 @@ type HistoryIo = {
writeErr: (text: string) => void;
};
async function exportHistorySession(
sessionId: string,
outputPath?: string,
): Promise<string> {
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
const html = generateConversationHTML(data, sessionId);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, html, "utf8");
return targetPath;
}
async function runHistoryDelete(
sessionId: string | undefined,
outputMode: CliOutputMode,
@@ -113,11 +136,7 @@ async function runHistoryExport(
}
try {
const targetPath = await exportHistorySession({
sessionId,
format: "html",
outputPath,
});
const targetPath = await exportHistorySession(sessionId, outputPath);
if (outputMode === "json") {
process.stdout.write(
@@ -142,7 +161,7 @@ export async function runHistoryList(input: {
outputMode: CliOutputMode;
workspaceRoot?: string;
io?: HistoryIo;
}): Promise<number> {
}): Promise<number | string> {
const io = input.io ?? {
writeln,
writeErr: (text: string) => process.stderr.write(`${text}\n`),
@@ -167,10 +186,23 @@ export async function runHistoryList(input: {
return 0;
}
for (const row of rows) {
io.writeln(formatHistoryListLine(row));
}
return 0;
disableOpenTuiGraphicsProbe();
const { renderHistoryStandalone } = await import("../tui/history-standalone");
return await renderHistoryStandalone({
rows,
refreshRows: async () =>
await listSessions(limit, {
workspaceRoot: input.workspaceRoot,
hydrate: false,
}),
onExport: async (sessionId: string) =>
await exportHistorySession(sessionId, undefined),
});
}
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
export {
exportHistorySession,
runHistoryDelete,
runHistoryExport,
runHistoryUpdate,
};
-151
View File
@@ -3,20 +3,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockLocalHubHasNoActiveSessions,
mockProbeHubServer,
mockReadHubDiscovery,
mockRequestHubDrain,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockLocalHubHasNoActiveSessions: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockRequestHubDrain: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -31,16 +27,13 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
requestHubDrain: mockRequestHubDrain,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -70,7 +63,6 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -96,152 +88,9 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
function createCommand() {
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
);
return {
cmd,
output,
errors,
exitCode: () => exitCode,
};
}
it("sends an un-drain request with drain --off", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain", "--off"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain --off",
{ off: true },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: false,
url: "ws://127.0.0.1:25463/hub",
});
});
it("drains without the off flag by default", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain",
{ off: false },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(true);
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(errors).toEqual([]);
expect(exitCode()).toBe(0);
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
// The drain was never lifted manually: the drained hub was replaced.
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toEqual({
upgraded: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
const { cmd, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(1);
expect(errors[0]).toContain("still serving sessions");
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub upgrade aborted",
{ off: true },
);
});
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
const { cmd } = createCommand();
cmd.configureOutput({ writeErr: () => {} });
for (const sub of cmd.commands) {
sub.configureOutput({ writeErr: () => {} });
}
await expect(
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
).rejects.toThrow("--wait requires a non-negative number of seconds.");
expect(mockRequestHubDrain).not.toHaveBeenCalled();
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
+1 -128
View File
@@ -1,17 +1,14 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
localHubHasNoActiveSessions,
probeHubServer,
readHubDiscovery,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command, InvalidArgumentError } from "commander";
import { version as cliVersion } from "../../package.json";
import { Command } from "commander";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -56,16 +53,6 @@ function resolveCliHubOwnerContext() {
: resolveSharedHubOwnerContext();
}
function parseWaitSeconds(value: string): number {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
throw new InvalidArgumentError(
"--wait requires a non-negative number of seconds.",
);
}
return parsed;
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -147,8 +134,6 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
@@ -162,117 +147,5 @@ export function createHubCommand(
}),
);
hub
.command("drain")
.description("Refuse new mutating work while accepted runs finish")
.option("--reason <text>", "Why the hub is draining")
.option("--off", "Lift the drain and accept new mutating work again")
.action(
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
io.writeErr("No hub is running.");
fail();
return;
}
const draining = cmdOptions.off !== true;
const ok = await requestHubDrain(
discovery.url,
discovery.authToken,
cmdOptions.reason ??
(draining ? "cline hub drain" : "cline hub drain --off"),
{ off: !draining },
);
if (!ok) {
io.writeErr(
draining
? "Hub drain request failed."
: "Hub un-drain request failed.",
);
fail();
return;
}
io.writeln(JSON.stringify({ draining, url: discovery.url }));
}),
);
hub
.command("upgrade")
.description(
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
)
.option(
"--wait <seconds>",
"How long to wait for the hub to go idle",
parseWaitSeconds,
120,
)
.action(
action(async (cmdOptions: { wait: number }) => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
const drained = await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade",
).catch(() => false);
// An aborted upgrade must hand the hub back: leaving it
// draining refuses all new mutating work until a restart.
const undrain = async (): Promise<void> => {
if (!drained) {
return;
}
await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade aborted",
{ off: true },
).catch(() => false);
};
try {
const deadline = Date.now() + cmdOptions.wait * 1_000;
let idle = false;
// Check at least once so --wait 0 still observes an idle hub.
for (;;) {
idle = await localHubHasNoActiveSessions(
discovery.url,
discovery.authToken,
).catch(() => true);
if (idle || Date.now() >= deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
if (!idle) {
await undrain();
io.writeErr(
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
);
fail();
return;
}
await stopHubServer(opts.cwd);
} catch (error) {
await undrain();
throw error;
}
}
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(JSON.stringify({ upgraded: true, url }));
}),
);
return hub;
}
+5 -276
View File
@@ -1,31 +1,5 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installMcpServer } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
runMcpUninstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
installMcpServer: vi.fn((options) => {
const { name, transport, warnings } =
actual.buildMcpInstallTransport(options);
return {
name,
status: "installed",
transport,
warnings,
};
}),
};
});
import { describe, expect, it, vi } from "vitest";
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
@@ -60,19 +34,6 @@ describe("mcp install command", () => {
});
});
it("shows mcp-remote marketplace entries as native remote servers", () => {
expect(
buildMcpInstallDefaults({
name: "linear",
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
}),
).toEqual({
name: "linear",
type: "streamableHttp",
url: "https://mcp.linear.app/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
@@ -127,52 +88,6 @@ describe("mcp install command", () => {
).toThrow(/only http and https are supported/);
});
it("builds direct stdio installs without shell-joining args", () => {
expect(
buildMcpInstallTransport({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
transport: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
},
warnings: [],
});
});
it("builds direct remote installs with headers and placeholder warnings", () => {
expect(
buildMcpInstallTransport({
name: "docs",
transport: "http",
headers: ["Authorization: Bearer <token>"],
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
}),
).toEqual({
name: "docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer <token>",
"X-Extra": "yes",
},
},
warnings: [
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
],
});
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
@@ -209,11 +124,11 @@ describe("mcp install command", () => {
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("checks for TTY before validating wizard install arguments", async () => {
it("checks for TTY before validating install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
@@ -224,193 +139,7 @@ describe("mcp install command", () => {
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("installs directly with --yes without requiring a TTY", async () => {
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
});
it("prints direct install JSON with --yes --json", async () => {
const writeln = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
targetArgs: ["node", "server.js"],
isTty: false,
yes: true,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
name: "fs",
status: "installed",
transport: {
type: "stdio",
command: "node",
args: ["server.js"],
},
});
});
});
describe("mcp uninstall command", () => {
let root = "";
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSettings(): string {
const settingsPath = join(root, "cline_mcp_settings.json");
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
},
},
keep: {
transport: { type: "stdio", command: "node" },
disabled: true,
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
return settingsPath;
}
function readSettings(settingsPath: string): Record<string, unknown> & {
mcpServers?: Record<string, unknown>;
} {
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
string,
unknown
> & { mcpServers?: Record<string, unknown> };
}
it("uninstalls the requested server and reports success", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
const written = readSettings(settingsPath);
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
expect(written.mcpServers?.keep).toEqual({
transport: { type: "stdio", command: "node" },
disabled: true,
});
expect(written.customTopLevelKey).toBe(true);
});
it("prints uninstall JSON with --json", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
name: "docs",
status: "uninstalled",
});
expect(writeln).toHaveBeenCalledTimes(1);
});
it("reports an error and leaves settings intact for an unknown server", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "missing",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
'MCP server "missing" is not installed.',
);
expect(writeln).not.toHaveBeenCalled();
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
it("rejects a blank name without rewriting settings", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: " ",
settingsPath,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
});
+60 -93
View File
@@ -1,35 +1,49 @@
import {
buildMcpInstallTransport as buildCoreMcpInstallTransport,
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
type McpUninstallOptions as CoreMcpUninstallOptions,
type McpUninstallResult as CoreMcpUninstallResult,
uninstallMcpServer,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
writeErr: (text: string) => void;
}
export interface McpInstallOptions extends CoreMcpInstallOptions {
export interface McpInstallOptions {
name: string;
targetArgs?: string[];
transport?: string;
io?: McpCommandIo;
isTty?: boolean;
json?: boolean;
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
yes?: boolean;
}
export interface McpInstallDirectResult {
name: string;
status: "installed";
transport: McpServerTransportConfig;
warnings: string[];
function normalizeTransportType(
value: string | undefined,
): McpAddDefaults["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 {
@@ -44,32 +58,36 @@ export function buildMcpInstallDefaults(options: {
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const { name, transport } = buildCoreMcpInstallTransport(options);
if (transport.type === "stdio") {
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: transport.type,
command: [transport.command, ...(transport.args ?? [])]
.map(quoteCommandArg)
.join(" "),
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: transport.type,
url: transport.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,
type,
url,
};
}
@@ -86,23 +104,11 @@ export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
if (options.yes) {
const result = installMcpServerDirect(options);
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
for (const warning of result.warnings) {
options.io?.writeErr(warning);
}
}
return 0;
}
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
"cline mcp install opens the MCP wizard and requires a TTY.",
);
}
const defaults = buildMcpInstallDefaults(options);
@@ -113,42 +119,3 @@ export async function runMcpInstallCommand(
return 1;
}
}
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
io?: McpCommandIo;
json?: boolean;
}
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
export function uninstallMcpServerDirect(
options: McpUninstallOptions,
): McpUninstallDirectResult {
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
return {
name: result.name,
status: result.status,
};
}
export async function runMcpUninstallCommand(
options: McpUninstallOptions,
): Promise<number> {
try {
const name = options.name?.trim() ?? "";
if (!name) {
throw new Error("MCP server name is required");
}
const result = uninstallMcpServerDirect({ ...options, name });
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
}
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
File diff suppressed because it is too large Load Diff
-61
View File
@@ -1,61 +0,0 @@
import { relative, sep } from "node:path";
import {
resolveClineDataDir,
resolveClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createProgram } from "./program";
/** Render an absolute path under `home` the way help text does: `~/...`. */
function tildePath(absolutePath: string, home: string): string {
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
}
describe("root option help text", () => {
const FAKE_HOME = "/home/cline-help-test";
const savedEnv: Record<string, string | undefined> = {};
beforeAll(() => {
// Pin the resolver inputs so the defaults below are the true defaults
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
setHomeDir(FAKE_HOME);
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("reports the actual resolver defaults for --config and --data-dir", () => {
// A wide help width keeps each option description on one line so the
// full default text can be matched.
const help = createProgram()
.configureHelp({ helpWidth: 500 })
.helpInformation();
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
// Sanity-check the resolvers themselves so the assertions below can't
// silently drift along with a resolver regression.
expect(configDefault).toBe("~/.cline");
expect(dataDirDefault).toBe("~/.cline/data");
expect(help).toContain(
`Configuration directory (default: ${configDefault})`,
);
expect(help).toContain(
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
);
});
});
+6 -4
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(
@@ -64,10 +64,13 @@ export function addRootOptions(cmd: Command): Command {
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option("--config <path>", "Configuration directory (default: ~/.cline)")
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline/data)",
"Use isolated local state at this directory path (default: ~/.cline)",
)
.option(
"--hooks-dir <path>",
@@ -133,7 +136,6 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
interactive: !!opts.tui,
outputMode: opts.json ? "json" : "text",
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
sandbox: !!opts.dataDir,
acpMode: !!opts.acp,
thinking: false,
+55 -230
View File
@@ -4,42 +4,13 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createScheduleCommand } from "./schedule";
const mockHubClientCommand = vi.hoisted(() => vi.fn());
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
providers: {} as Record<string, { provider?: string; model?: string }>,
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
NodeHubClient: class {
command = mockHubClientCommand;
constructor(options: Record<string, unknown>) {
mockNodeHubClientCtor(options);
}
async connect(): Promise<void> {}
close(): void {}
},
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
}
getProviderSettings(providerId: string) {
return mockProviderSettings.providers[providerId];
}
},
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
@@ -76,8 +47,6 @@ async function runScheduleCommand(
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it('prints "No schedules found." for empty non-json list output', async () => {
@@ -85,7 +54,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -107,21 +76,18 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["No schedules found."]);
// Schedule commands are workspace-scoped: the hub client must register
// with a workspace context (and the hub auth token) before commanding.
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: process.cwd(),
cwd: process.cwd(),
authToken: "test-token",
}),
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.list",
payload: {
limit: 100,
enabled: undefined,
tags: undefined,
},
},
);
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
limit: 100,
enabled: undefined,
tags: undefined,
});
});
it("keeps JSON list output unchanged when --json is provided", async () => {
@@ -129,7 +95,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -151,163 +117,13 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["[]"]);
expect(mockHubClientCommand).toHaveBeenCalled();
expect(mockSendHubCommand).toHaveBeenCalled();
});
});
describe("runScheduleCommand create", () => {
describe("runScheduleCommand create delivery metadata", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("uses the last used provider and model when both flags are omitted", async () => {
mockProviderSettings.lastUsed = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: "/tmp/workspace",
cwd: "/tmp/workspace",
authToken: "test-token",
}),
);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
);
});
it("uses an explicit provider with that provider's configured model", async () => {
mockProviderSettings.lastUsed = {
provider: "cline",
model: "openai/gpt-5.3-codex",
};
mockProviderSettings.providers.anthropic = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
);
});
it("fails when an explicit provider has no configured model and no model flag", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(1);
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockHubClientCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -315,7 +131,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_delivery" } },
});
@@ -354,17 +170,21 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
},
},
},
}),
}),
},
);
});
});
@@ -372,8 +192,6 @@ describe("runScheduleCommand create", () => {
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("preserves exported modelSelection providerId/modelId values", async () => {
@@ -381,7 +199,7 @@ describe("runScheduleCommand import", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -422,12 +240,16 @@ describe("runScheduleCommand import", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
},
);
});
});
@@ -435,8 +257,6 @@ describe("runScheduleCommand import", () => {
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("writes JSON content to the --to file path", async () => {
@@ -451,7 +271,7 @@ describe("runScheduleCommand export", () => {
prompt: "review status",
workspaceRoot: "/tmp/workspace",
};
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
@@ -491,9 +311,14 @@ describe("runScheduleCommand export", () => {
const written = await readFile(targetPath, "utf8");
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
scheduleId: "sched_abc",
});
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.get",
payload: { scheduleId: "sched_abc" },
},
);
} finally {
await rm(targetPath, { force: true });
}
@@ -509,7 +334,7 @@ describe("runScheduleCommand export", () => {
name: "Weekly Sync",
cronPattern: "0 9 * * 1",
};
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});

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