Compare commits

..

1 Commits

Author SHA1 Message Date
abeatrix 166b2e9fd4 test(test): fix Windows shell execution in bash tests
Update the bash executor test helper to prefix quoted executables with the PowerShell call operator on Windows. This ensures process.execPath is invoked correctly when paths contain spaces or are quoted, keeping cross-platform test behavior consistent.
2026-05-28 17:07:37 -07:00
3461 changed files with 233271 additions and 530772 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
+2 -2
View File
@@ -41,11 +41,11 @@ fi
# Install project dependencies
echo "Installing dependencies..."
bun run install:all
npm run install:all
# Generate gRPC/protobuf types (required for TypeScript)
echo "Generating proto types..."
bun run protos
npm run protos
echo ""
echo "Session setup complete!"
-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
-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 are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. 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 + updater artifact + `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, signs the updater artifact with the Tauri updater key, 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 and 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). 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.
-55
View File
@@ -1,55 +0,0 @@
# Bun (tooling) and Node (runtime)
This repo uses **bun** for package management and task running, and **Node** as
the execution runtime. Both are correct at the same time; the distinction is the
source of most confusion, so keep it straight before editing scripts, configs,
docs, or comments.
## Use bun for tooling
- `bun install` (never `npm install` / `npm ci`)
- `bun run <script>` (never `npm run <script>`)
- `bunx <bin>` (never `npx <bin>`)
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
- `bun run --parallel ...` for parallel tasks
The root `bun.lock` is the single lockfile for the whole workspace, including
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
lockfiles.
## Node is the runtime — do NOT rewrite these to bun
The build product runs on Node: the VS Code extension host loads
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
Node process. The following are Node runtime/ABI references and are correct as-is:
| Reference | Why it is Node |
|-----------|----------------|
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
the runtime/ABI target, not tooling. If unsure, leave it.
## Tests: bun vs the VS Code host
A test file's runner is decided by its import:
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
discovers these by the `bun:test` import and runs one isolated bun process per
file. `build-tests.js` excludes them from the integration compile so the
`bun:test` builtin never reaches Node.
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
extension host (Node). These exercise the live `vscode` API and cannot run
under bun.
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
needs the real extension host.
-129
View File
@@ -1,129 +0,0 @@
# Debug Harness
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
## Quick start
```bash
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
```
## Data Isolation
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
This prevents the debugee's logout from logging out the debugger, and vice versa.
Override with `--cline-dir /tmp/test-dir`. Check with `status()``clineDir`.
## Browser Capture & OAuth
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
- POSTed in real-time to `/captured-url` on the harness server
- Queryable via `oauth.captured_urls`
### OAuth API
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
### OAuth testing flow
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
is captured. To complete: open the captured URL in a real browser (it redirects back to the
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
extension host can't `require()` the handler. To actually deliver the callback, call the
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
(`bun run dev:mcp-oauth-test-server`).
## Navigating Views — Use Commands, Not Clicks
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
Registered in `src/registry.ts`:
| Command | View |
|---------|------|
| `cline.accountButtonClicked` | Account / sign-in |
| `cline.historyButtonClicked` | Task history |
| `cline.settingsButtonClicked` | Settings |
| `cline.mcpButtonClicked` | MCP servers |
| `cline.plusButtonClicked` | New task (chat) |
| `cline.worktreesButtonClicked` | Worktrees |
```bash
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
```
## Key commands
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
- **`launch`** / **`shutdown`** — lifecycle
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}`**use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
- **`ui.open_sidebar`** — open the Cline sidebar
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
- **`ext.call_stack`** — inspect when paused
- **`web.evaluate`** `{expression}` — eval in webview
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
- **`ui.command_palette`** `{command}` — run VSCode command
## Typical Session
```bash
# 1. Launch
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
# 3. Navigate to view
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
# 4. Check captured OAuth URLs if testing auth
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
# 5. Verify
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
```
## Caveats
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
```bash
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
- **macOS only** for now (Playwright Electron launch behavior).
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
See `src/dev/debug-harness/README.md` for full API reference.
+103 -103
View File
@@ -13,57 +13,11 @@ This file is the secret sauce for working effectively in this codebase. It captu
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## Miscellaneous
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- 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.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
## Searching the Codebase — Avoiding Build Output
Several directories contain build output or generated code that produces
noisy or unusable results with `search_files` / `grep`:
| Directory | What it is | Why it's a problem |
|-----------|-----------|-------------------|
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
| `dist-standalone/` | Standalone build output | Same minification issue |
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
| `node_modules/` | Dependencies | Huge, not project source |
### How to skip build output
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
```
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
```
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
`"*.tsx"`, `"*.proto"`.
**`grep` directly** — Exclude build dirs and restrict to source extensions:
```bash
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
```
### When you must search minified files
Sometimes you need to verify what got bundled (e.g., checking if a change
made it into the build). Minified files are typically one long line, so
normal `grep` shows the entire file as context. Use these approaches:
- **`grep -oP`** to extract just the match with limited surrounding context:
```bash
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
```
- **`read_file`** on files in `out/src/` — these have source maps and are
more readable than `dist/extension.js` (which is the fully bundled output).
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
used to trace minified output back to original source locations.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
@@ -74,7 +28,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `bun run protos`** after any proto changes—generates types in:
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
@@ -94,15 +48,104 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding a New API Provider
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
**Other files to update when adding a provider:**
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
- `src/shared/providers/providers.json` - Add to provider list for dropdown
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
- `webview-ui/src/utils/validate.ts` - Add validation case
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
## Responses API Providers (OpenAI Codex, OpenAI Native)
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
**Symptoms of broken native tool calling:**
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
- Tool arguments get duplicated or malformed
- The model responds but tools aren't recognized
**Root causes to check:**
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
**When adding a new Responses API provider:**
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
3. The variant matcher and task runner will handle the rest automatically
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
@@ -110,26 +153,28 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
Exception: State needed immediately at extension startup (before cache is ready)
Example pattern:
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading after initialization
const value = controller.stateManager.getGlobalStateKey("myKey")
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
@@ -158,48 +203,3 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
## Debug Harness: clear inherited VSCode/Electron env vars before launching
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
extension host, an integrated terminal, or an agent running inside VSCode), the
parent's VSCode/Electron env vars leak into the child and break the launch.
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
as plain Node, so it rejects every VSCode CLI flag. Symptom:
```
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
Error: Process failed to launch! (Playwright _electron.launch)
```
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
env inheritance. Fix: strip the inherited vars before starting the harness:
```bash
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
```
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
present means you must scrub before launching.
Other harness notes confirmed in practice:
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
`require` and module-internal functions aren't reachable as globals. To inspect
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
— don't try to `require()` the bundle.
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
`SyntaxError: Unexpected token ';'`.
- Webview settings inputs are `vscode-text-field` web components with debounced React
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
`ui.press Tab`, or click the dropdown option) to make the value persist.
+1 -1
View File
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
```bash
bun run protos
npm run protos
```
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
-26
View File
@@ -1,26 +0,0 @@
# SDK Adapter
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
## Conventions
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
before implementing against an SDK surface.
2. **Reference the pre-SDK implementation when replacing a module.** Add a
`// Replaces classic src/core/... (see origin/main)` header and use
`kb_search(name="cline", commit="origin/main")` or
`git show origin/main:path` to consult the prior implementation.
3. **Single entry point.** There is one codepath — the SDK adapter. No
`CLINE_SDK` env flag.
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
casts are unnecessary outside parse/compute boundaries.
## Debug harness
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
- **Use the command palette** to navigate tabs in the debug harness.
+1 -1
View File
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
Commit with message format: `v{VERSION} Release Notes (hotfix)`
+3 -15
View File
@@ -7,10 +7,10 @@ body:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: cline-surface
id: plugin-type
attributes:
label: Cline Surface
description: Which Cline surface are you reporting a bug for?
label: Plugin Type
description: Which plugin are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
@@ -59,18 +59,6 @@ body:
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
id: system-info
attributes:
+7 -7
View File
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
- **MCP**: `src/services/mcp/McpHub.ts`.
## Build & Test (Critical — non-obvious commands)
- **Build**: `bun run compile` — NOT `bun run build`.
- **Watch**: `bun run watch` (extension + webview).
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
- **Build**: `npm run compile` — NOT `npm run build`.
- **Watch**: `npm run watch` (extension + webview).
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
## Protobuf RPC Workflow (4 steps)
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
2. **Generate**: `bun run protos`.
2. **Generate**: `npm run protos`.
3. **Backend handler**: `src/core/controller/<domain>/`.
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
@@ -38,13 +38,13 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts``cline-message.ts``ChatRow.tsx`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
## Modifying System Prompt
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
## Global State Keys (silent failure risk)
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
## Slash Commands (3 places)
- `src/core/slash-commands/index.ts` — definitions.
+1 -1
View File
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
<!-- Put an 'x' in all boxes that apply -->
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
### Screenshots
+8 -36
View File
@@ -33,7 +33,7 @@ permissions:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
publish-main:
@@ -105,12 +105,12 @@ jobs:
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "apps/cli/package.json has invalid version: ${VERSION}"
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
@@ -147,7 +147,7 @@ jobs:
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -194,7 +194,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag latest
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Get Previous CLI Tag
id: prev_tag
@@ -206,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)
@@ -215,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:
@@ -276,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"
@@ -403,7 +375,7 @@ jobs:
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -452,7 +424,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag nightly
working-directory: apps/cli
working-directory: sdk/apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
-655
View File
@@ -1,655 +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
release:
name: Create GitHub release
needs: [validate, build]
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) || '' }}"
-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 }}"
+29 -232
View File
@@ -1,40 +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:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
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
@@ -43,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
@@ -53,106 +27,49 @@ 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
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: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
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.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
# 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 root dependencies
run: npm ci --include=optional
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci --include=optional
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- 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"
# 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 }}"
- 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
@@ -160,129 +77,10 @@ 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
run: npm 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: |
@@ -290,11 +88,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}"
+28 -138
View File
@@ -27,10 +27,6 @@ permissions:
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
@@ -106,61 +102,24 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
echo "Using existing tag '$TAG'."
fi
git checkout --detach "$TAG_REF^{commit}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
# ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm install` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
- name: Install root dependencies
run: npm install --include=optional
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Install webview-ui dependencies
run: cd webview-ui && npm install --include=optional
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally (npm is available via setup-node). vsce is installed globally too
# to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -180,87 +139,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 }}
@@ -284,23 +162,35 @@ jobs:
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix. --no-dependencies: the extension
# is fully esbuild-bundled, and under the bun workspace the @cline/*
# deps are symlinks pointing outside the package, so without this vsce
# would walk them and pull the whole monorepo into the .vsix.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# These scripts run under `node scripts/publish-marketplace.mjs`;
# bun run just launches them. Node + npm (for `npx ovsx`) come from
# setup-node above.
if [ "$RELEASE_TYPE" = "pre-release" ]; then
bun run publish:marketplace:prerelease
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
bun run publish:marketplace
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -330,7 +220,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"
+33 -53
View File
@@ -45,16 +45,12 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/playwright*.ts'
@@ -80,30 +76,34 @@ 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:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
# Cache 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
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: bun-cache
id: root-cache
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
path: apps/vscode/node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: apps/vscode/webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
# Cache VS Code installation
- name: Cache VS Code
@@ -112,6 +112,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
@@ -122,39 +124,20 @@ jobs:
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before building/packaging the extension for E2E.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
- name: Assert better-sqlite3 native binary present
# Force bash: the Windows runner defaults to pwsh, which can't parse this
# POSIX test. Git Bash ships on GitHub's windows-latest images.
shell: bash
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
# .bin on PATH. No global install needed.
- name: Install vsce
run: npm install -g @vscode/vsce
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
@@ -163,18 +146,15 @@ jobs:
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a bun run test:e2e:optimal
run: xvfb-run -a npm run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
run: npm run test:e2e:optimal
# 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/
+58 -129
View File
@@ -45,16 +45,13 @@ jobs:
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.mocharc.json'
- 'apps/vscode/.nycrc*.json'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
@@ -63,13 +60,9 @@ jobs:
- 'apps/vscode/proto/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/testing-platform/**'
- 'apps/vscode/testing-platform/package.json'
- 'apps/vscode/tests/specs/**'
- 'apps/vscode/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/package-lock.json'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/esbuild.mjs'
@@ -89,44 +82,31 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
# Single root install resolves the entire bun workspace (apps/vscode,
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
# so the previous per-package `npm ci` steps collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; their dist/
# output must be built before the extension can type-check/compile.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
- name: Run Quality Checks (Parallel)
run: bun run ci:check-all
run: npm run ci:check-all
vscode-test:
needs: [detect-changes, quality-checks]
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:
@@ -141,43 +121,27 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
# Single root install resolves the entire bun workspace 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
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling/testing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
- name: Assert better-sqlite3 native binary present
- name: Set up NPM on Windows
if: runner.os == 'Windows'
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: The old `npm config set script-shell bash` step is intentionally
# removed. Scripts are now launched with `bun run`, which uses Bun's own
# built-in cross-platform shell rather than npm's configured script-shell,
# so that npm-specific Windows workaround no longer applies. Bash-dependent
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
# invoked explicitly via `bash ...` from within the package scripts, and
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
# the workflow `run:` blocks below.
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
- name: Cache VS Code test runtime
if: runner.os == 'Windows'
@@ -189,51 +153,24 @@ jobs:
# Build the extension and tests (without redundant checks)
- name: Build Tests and Extension
id: build_step
run: bun run ci:build
run: npm run ci:build
- name: Vitest Suites (SDK adapter + model catalog)
id: vitest_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
# The vitest config sets passWithNoTests: true, so a broken glob/alias
# would "pass" with zero tests. Capture output and assert a non-zero
# test count to guard against silent skips.
run: |
set -o pipefail
bun run test:vitest 2>&1 | tee vitest-output.log
# Strip ANSI color codes before matching — vitest colorizes the
# "Tests N passed" summary, so the count is not adjacent to the
# "Tests" label in the raw bytes.
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
echo "ERROR: vitest reported zero tests (possible silent skip)."
exit 1
fi
- name: Unit Tests (bun) - Linux
- name: Unit Tests with coverage - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
# The runner exits non-zero on any failure and prints a final
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
# guard against an empty glob silently "passing".
run: |
set -o pipefail
bun run test:unit 2>&1 | tee unit-output.log
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
exit 1
fi
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
- name: Unit Tests (bun) - Non-Linux
- name: Unit Tests - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
bun run test:unit
npm run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a bun run test:coverage
run: xvfb-run -a npm run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
@@ -241,7 +178,7 @@ jobs:
run: |
for attempt in 1 2 3; do
echo "Running extension integration tests (attempt ${attempt}/3)"
if bun run test:integration; then
if npm run test:integration; then
exit 0
fi
@@ -259,7 +196,7 @@ jobs:
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
bun run test:coverage
npm run test:coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
@@ -268,6 +205,7 @@ jobs:
with:
name: pr-coverage-reports
path: |
apps/vscode/coverage-unit/lcov.info
apps/vscode/webview-ui/coverage/lcov.info
test-platform-integration:
@@ -281,45 +219,36 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
bun-version: 1.3.14
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
# Single root install resolves the whole bun workspace, including the
# testing-platform package, so the separate per-package `npm ci` steps
# (extension + webview-ui + testing-platform) collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
- name: Install root dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling the standalone core.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
- name: Download ripgrep binaries
run: bun run download-ripgrep
run: npm run download-ripgrep
- name: Compile Standalone
run: bun run compile-standalone
run: npm run compile-standalone
- name: Install testing platform dependencies
run: cd testing-platform && npm ci
- name: Running testing platform integration spec tests
timeout-minutes: 7
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
@@ -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}`)
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -29,7 +29,7 @@ jobs:
}
// Check if VSCode Extension is selected
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
if (!labels.includes('VS Code')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -41,7 +41,7 @@ jobs:
}
// Check if CLI is selected
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
if (!labels.includes('CLI')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -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}`)
+9 -94
View File
@@ -26,7 +26,7 @@ on:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
test:
@@ -148,7 +148,7 @@ jobs:
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
TIMESTAMP=$(date +%s)
@@ -166,11 +166,11 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: bun sdk/scripts/version.ts "$VERSION"
run: bun scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun sdk/scripts/check-publish.ts
run: bun scripts/check-publish.ts
- name: Prepare package tarball directory
if: steps.check_commits.outputs.skip != 'true'
@@ -187,7 +187,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/shared
cd packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -199,7 +199,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/llms
cd packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -211,7 +211,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/agents
cd packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -223,7 +223,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/core
cd packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -235,7 +235,7 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/sdk
cd packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -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) || '' }}"
+4 -4
View File
@@ -21,7 +21,7 @@ permissions:
defaults:
run:
working-directory: .
working-directory: sdk
jobs:
quality-checks:
@@ -96,12 +96,12 @@ jobs:
- name: Run SDK Tests (Windows)
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './sdk/packages/**' test
run: bun -F './packages/**' test
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
run: bun sdk/scripts/ci-node-smoke.ts
run: bun scripts/ci-node-smoke.ts
- name: Run TUI e2e tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
@@ -109,4 +109,4 @@ jobs:
- name: Verify packages are publishable
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun sdk/scripts/check-publish.ts
run: bun scripts/check-publish.ts
-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}'"
-23
View File
@@ -13,9 +13,6 @@ tmp
pnpm-lock.yaml
.clineignore
.cline/enterprise
.cline/remote-config
**/.cline/remote-config
.venv
.actrc
CLAUDE.local.md
@@ -64,17 +61,6 @@ tests/**/cache
# Should never be committed: only exists if a publish aborts mid-swap.
.README.github.bak
# Tauri generated code
apps/*/src-tauri/gen
apps/*/src-tauri/bin
apps/examples/*/src-tauri/gen
apps/examples/*/src-tauri/bin
# Tauri UI test snapshots
apps/*/src/tests/.tui-test
apps/*/src/tests/tui-traces
apps/vscode/webview-ui/src/**/*.js
apps/vscode/webview-ui/src/**/*.js.map
# SDK Session files / User data
.cline/data
@@ -84,12 +70,3 @@ apps/vscode/webview-ui/src/**/*.js.map
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
apps/examples/desktop-app/.cursor/settings.json
+1 -11
View File
@@ -1,11 +1 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
cd apps/vscode && bunx lint-staged
lint-staged
+7 -6
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"
}
},
{
@@ -128,7 +126,10 @@
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
],
"preLaunchTask": "compile-standalone",
"runtimeExecutable": "bun",
"runtimeExecutable": "npx",
"runtimeArgs": [
"tsx"
],
"program": "scripts/test-standalone-core-api-server.ts",
"envFile": "${workspaceFolder}/apps/vscode/.env",
"env": {
@@ -182,7 +183,7 @@
"name": "Open Storybook",
"type": "node",
"request": "launch",
"runtimeExecutable": "bun",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"storybook"
+1 -14
View File
@@ -22,24 +22,11 @@
},
// Enable Lint and format using Biome
"biome.enabled": true,
"biome.requireConfiguration": true,
"prettier.enable": false,
"editor.defaultFormatter": "biomejs.biome",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.removeUnused.biome": "always",
"source.removeUnusedImports": "always",
"source.organizeImports.biome": "always"
},
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
+30 -51
View File
@@ -5,8 +5,8 @@
"tasks": [
{
"label": "compile-standalone",
"type": "shell",
"command": "bun run compile-standalone",
"type": "npm",
"script": "compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -18,8 +18,8 @@
},
{
"label": "npm: protos",
"type": "shell",
"command": "bun run protos",
"type": "npm",
"script": "protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -64,11 +64,11 @@
"group": "build"
},
{
"type": "shell",
"command": "bun run build:webview",
"type": "npm",
"script": "build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -85,11 +85,11 @@
}
},
{
"type": "shell",
"command": "bun run build:webview:test",
"type": "npm",
"script": "build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -107,23 +107,23 @@
}
},
{
"type": "shell",
"command": "bun run dev:webview",
"type": "npm",
"script": "dev:webview",
"group": "build",
"problemMatcher": [
{
"pattern": [
{
"regexp": "^(?!)((?:.*))$",
"kind": "file",
"regexp": ".",
"file": 1,
"message": 1
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^Building webview for|^\\s*VITE",
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
"beginsPattern": ".",
"endsPattern": "."
}
}
],
@@ -144,8 +144,8 @@
}
},
{
"type": "shell",
"command": "bun run watch:esbuild",
"type": "npm",
"script": "watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -169,8 +169,7 @@
"isBackground": true,
"label": "npm: watch:esbuild",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -184,8 +183,8 @@
}
},
{
"type": "shell",
"command": "bun run watch:esbuild:test",
"type": "npm",
"script": "watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -209,8 +208,7 @@
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos",
"build-sdk:debug"
"npm: protos"
],
"presentation": {
"group": "watch",
@@ -225,8 +223,8 @@
}
},
{
"type": "shell",
"command": "bun run watch:tsc",
"type": "npm",
"script": "watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -243,9 +241,8 @@
}
},
{
"type": "shell",
"command": "bun run watch-tests",
"label": "npm: watch-tests",
"type": "npm",
"script": "watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"dependsOn": [
@@ -283,8 +280,8 @@
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
},
{
"type": "shell",
"command": "bun run storybook",
"type": "npm",
"script": "storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
@@ -311,25 +308,7 @@
"$tsc"
],
"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"
}
"cwd": "${workspaceFolder}/sdk"
}
}
],
-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.
-400
View File
@@ -1,405 +1,5 @@
# Changelog
## [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
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
- Add Claude Fable 5 model support.
### Fixed
- Fix MiniMax M3 thinking controls across gateways.
### Changed
- Clean up the Codex model list.
## [3.88.1]
### Added
- Add a debug section in settings for Cline testers.
### Fixed
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
## [3.88.0]
### Added
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
### Fixed
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
### Changed
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
## [3.87.0]
### Added
- Add MiniMax M3 model support.
### Fixed
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
## [3.86.2]
### Fixed
- Fix `@` file mentions and workspace file search on VS Code 1.122+ by resolving the new bundled `@vscode/ripgrep-universal` per-platform binary layout before falling back to legacy ripgrep paths.
## [3.86.1]
### Fixed
- Fix `@` file mentions failing to find files in some environments (notably VS Code Remote SSH, and after certain VS Code updates) by keeping the file-search fallback alive when the workspace index or bundled ripgrep binary is unavailable.
## [3.86.0]
### Added
+2
View File
@@ -0,0 +1,2 @@
@.clinerules/general.md
@.clinerules/network.md
+15 -15
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>
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
3. Install [bun](https://bun.com)
4. Install the necessary dependencies for the extension and webview-gui:
```bash
cd apps/vscode && bun run install:all && cd ../..
cd apps/vscode && npm run install:all && cd ../..
cd sdk && bun run build && cd ..
```
5. Generate Protocol Buffer files (required before first build):
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
3. Testing
- Run `cd apps/vscode && bun run test` to run tests locally.
- Before submitting PR, run `bun run format:fix` to format your code
- Run `cd apps/vscode && npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
### Extension
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
2. **Local Development**
- cd into the vscode extension, `cd apps/vscode`
- Run `bun run install:all` to install dependencies
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
- Run `bun run test` to run tests locally
- Run `npm run install:all` to install dependencies
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
- Run `npm run test` to run tests locally
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
- Before submitting PR, run `bun run format:fix` to format your code
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
- Before submitting PR, run `npm run format:fix` to format your code
3. **Linux-specific Setup**
VS Code extension tests on Linux require the following system libraries:
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
2. **Code Quality**
- Run `bun run lint` to check code style
- Run `bun run format` to automatically format code
- Run `npm run lint` to check code style
- Run `npm run format` to automatically format code
- All PRs must pass CI checks which include both linting and formatting
- Address any warnings or errors from linter before submitting
- Follow TypeScript best practices and maintain type safety
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
3. **Testing**
- Add tests for new features
- Run `bun test` to ensure all tests pass
- Run `npm test` to ensure all tests pass
- Update existing tests if your changes affect them
- Include both unit tests and integration tests where appropriate
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
- **Running E2E tests:**
```bash
bun run test:e2e # Build and run all E2E tests
bun run e2e # Run tests without rebuilding
bun run test:e2e -- --debug # Run with interactive debugger
npm run test:e2e # Build and run all E2E tests
npm run e2e # Run tests without rebuilding
npm run test:e2e -- --debug # Run with interactive debugger
```
- **Writing E2E tests:**
+8 -12
View File
@@ -51,7 +51,7 @@ for CI/CD and scripting.
npm i -g cline
```
<a href="./apps/cli/README.md">Learn more</a>
<a href="./sdk/apps/cli/README.md">Learn more</a>
<br><br>
</td>
@@ -129,7 +129,7 @@ npm install @cline/sdk
| Product | Description | Location | CHANGELOG |
|---------|------------|--------------|--------------|
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
@@ -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 |
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
```bash
# Connect to Telegram
cline connect telegram -k $BOT_TOKEN
# Connect to Slack through webhook
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
# Connect to Slack using socket mode
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
```
## Headless CLI for CI/CD
@@ -226,7 +222,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'
```
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": [
"../sdk/biome.json"
],
"linter": {
"rules": {
"a11y": {
"noStaticElementInteractions": "warn"
}
}
}
}
-633
View File
@@ -1,633 +0,0 @@
# Cline CLI Changelog
## 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)
- Added a prefilled MCP install wizard command for quicker MCP server setup
- Improved error handling and messaging when plugin MCP OAuth authorization fails
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
## 3.0.26
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
## 3.0.25
- Added ClinePass support, with selectable ClinePass models in the model picker
- Made model picker sections expandable
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
- Encouraged parallel tool calls for faster task execution
- Capped tool output for bash commands and file reads to keep large output within context limits
- Allowed ranged reads on large files
- Fixed apply_patch to fail when a hunk is skipped
- Fixed run_commands to return captured stdout on failure and handle split heredocs
- Fixed search tools to treat zero results as success
- Fixed disabled-reasoning handling for StepFun flash
- Fixed history resume rendering isolation
- Fixed the Hugging Face URL
- Fixed Cline OAuth token formatting in provider config
## 3.0.24
- Plugin commands can now submit prompts to the agent
- Added support for overriding the API base URL
- Open the verification URL automatically when starting device authentication
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
- Suppressed flickering console windows on Windows
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 3.0.23
- Fixed Vertex AI GCP settings configuration
- Fixed the Azure Foundry API version
- Added support for configured agents as subagent tools
- Centralized OAuth management into the SDK
- Fixed an error caused by disabled reasoning on Fable 5
## 3.0.22
- Added support for the Claude Fable 5 model
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
## 3.0.21
- Added a global auto-update setting that controls automatic updates on CLI startup
- Added a Cline credits refill link
- Fixed scrolling for inline ask-question responses
- Fixed connector thread session routing and stale hub session handling
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
- Fixed empty message content replay for Bedrock
- Cleaned up the OpenAI Codex model list
## 3.0.20
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
## 3.0.19
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
## 3.0.18
- Fix Slack channel mentions so replies post in the original message's thread.
- Fix the abort indicator to clear immediately when a task is cancelled.
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
## 3.0.17
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
## 3.0.16
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
- Add Slack socket mode support.
- Allow a custom base URL for Anthropic vendor-type providers.
- Fix OAuth token migration for users signed in through the old extension.
- Use a union schema for read-files tool input validation.
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
## 3.0.15
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
- Support global AGENTS rules so agent rules can be applied across all sessions, not just per-project.
- Let plugins contribute static or dynamic rule content when installed in the sandbox.
- Bind Discord sessions to individual message authors so different Discord users no longer share chat state in a thread.
- Support participant mute targets in Discord: resolve `/mute` and `/unmute` from user mentions or raw user IDs to mute a specific participant in a thread.
- Make OAuth URLs clickable in the TUI.
- Refresh the bundled model catalog, adding Claude Opus 4.8, Moonshot Kimi K2.6, and Qwen3.7 Max (with cache support).
- Discover SDK skill directories that are symlinked, including handling circular symlinks.
- Steer active connector sessions across turn keys by matching on session ID, so replies continue the existing session instead of starting a duplicate.
- Stop the Discord connector after repeated identical errors (per thread, within a time window) to prevent error messages from flooding a channel.
- Fix Discord connector registration and reply fallback handling.
- Fix SAP AI Core to use the AI SDK community provider.
- Log ACP output as diagnostics instead of errors so normal output no longer appears as errors.
## 3.0.14
- Fix OTEL telemetry variable bundling so telemetry is correctly enabled in compiled CLI builds: guard against environments where `process.env` is undefined and remove optional chaining so bundlers can inline the values at build time.
## 3.0.13
- Show a loading dialog while resuming a session from history so the TUI no longer appears frozen during the load.
- Speed up the `/clear` command by deferring new session creation until you send the next prompt, so clearing no longer blocks on spinning up an empty session.
## 3.0.12
- Show a loading dialog while the config screen switches provider or model so the transition no longer looks frozen.
- Render the ask question tool prompt inline with the conversation so the question and suggested answers stay attached to the assistant turn that asked them, instead of appearing in a separate modal.
- Allow manual `cline update` runs to install the latest published version immediately, bypassing the release age gate that delays automatic updates.
- Refresh the bundled SDK to 0.0.42, updating the model catalog.
## 3.0.11
- Fix a regression in the ChatGPT OAuth provider where requests failed with `max_output_tokens not supported`, by restoring the full output token budget instead of applying an implicit cap.
- Hide the `Space toggle` hint in the config footer when the highlighted row is not toggleable (rules, agents, hooks).
- Authenticate Vertex Gemini through Google auth when `gcp.projectId` is configured, and surface the full Vertex model list instead of only Claude models.
- Include tool names in tool result content blocks so message logs and session history consistently track which tool produced each result.
## 3.0.10
- Install plugins from `file://` URLs in addition to npm and git sources.
- Show Ollama API key note in TUI settings so users know when to provide an API key.
- Keep interactive sessions alive when idle or awaiting approval instead of treating them as ended, and stop reading message files for every session when `hydrate: false`.
- Add Poolside as a provider.
- Add Gemini 3.5 Flash to the Gemini provider model list.
- Auto-detect Telegram bot username from the bot token so the Telegram connector no longer requires it to be configured separately.
- Notify connectors when a scheduled execution fails, not just when it succeeds.
- Bake OTEL telemetry variables into the CLI at build time so telemetry works in nightly and production builds.
- Preserve model output token limits from the SDK model catalog so context window math matches the upstream provider.
- Soften the visual treatment of rejected tool calls in the TUI.
- Hide the skills tool from the system prompt when skills are disabled, and refresh slash commands after toggling a skill.
- Restore AWS Bedrock profile-based auth during legacy config migration so profiles set via `awsAuthentication: "profile"` are preserved without `awsUseProfile`.
- Cache global settings reads keyed by file mtime so repeated reads skip the JSON parse and zod validation on the hot path.
## 3.0.9
- Speed up CLI startup with plugins by loading sandboxed plugins concurrently and caching plugin tool descriptors per plugin, provider, and model.
- Speed up plugin and tool config toggles by updating the TUI optimistically and persisting changes without reloading the full config or reimporting plugins.
- Restore fuzzy ranking for the @-mention file picker so the most relevant files appear first.
- Keep the interactive CLI session alive after cancelling a task instead of tearing the session down.
- Accept dash-prefixed prompts when passed after `--`, so prompts starting with `-` are no longer parsed as flags.
- Recover from hub abort cleanup failures so a cancel that hits an error no longer crashes the runtime host.
- Route GLM thinking through provider metadata so thinking-enabled GLM models behave correctly through the gateway.
## 3.0.8
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
## 3.0.7
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
- Align the ChatGPT OAuth model catalog with the Codex provider list so the available models match the subscription tier.
## 3.0.6
- Fix ChatGPT provider model list to include the codex variants and the gpt-5.2, gpt-5.4, and gpt-5.4-mini subscription models.
## 3.0.5
- Show plugin-provided tools and slash commands in the CLI settings dialog by hydrating them through the sandbox.
- Preserve hydrated plugin tools and config reload options when toggling settings, so they no longer disappear after a toggle.
## 3.0.4
- Improve light theme TUI colors so chat, status bar, tool output, and syntax highlighting render with better contrast on light terminals.
- Fix plugin tools failing in the production npm build by bundling the SDK deps plugins import at runtime.
## 3.0.3
- Add `--worktree` flag that auto-creates a fresh git worktree under `~/.cline/worktrees/` and runs the task there. Works with `--taskId` and `--continue` so you can resume a task in an isolated worktree to try a different approach.
- Show session status in the CLI history view and refresh status rows in place while the standalone history TUI is open.
- Restore the OpenAI compatible provider in the auth flow and preserve stored model metadata when configuring or migrating OpenAI-compatible providers.
- Fix dropped macOS screenshots when pasting them into the TUI or asking the agent to read them: paths containing U+202F (narrow no-break space) and other Unicode variants now resolve to the real file instead of failing with ENOENT.
- Accept bearer token auth for AWS Bedrock and map AWS profiles correctly when configuring the Bedrock gateway.
- Honor `--thinking none` for Ollama models that ship with reasoning enabled by default.
- Recover from detached hub event errors instead of crashing the session.
- Refine the shared system prompt with clearer guidance on tool output formatting, unsupported file reads, long-running shell commands, and final verification before completing a task.
## 3.0.2
- Fix token count display showing inflated numbers in the TUI.
## 3.0.1
- Fix CLI release cleanup scripts so they work correctly on Windows.
- Fix the kanban migration notice wording in the TUI.
## 3.0.0
Introducing our new Cline CLI built on our new SDK and comes with a snappy new TUI.
Install:
```sh
npm install -g cline
```
For nightly builds:
```sh
npm install -g cline@nightly
```
## 0.0.13
- Detect prompt-cache support from cache write pricing so providers with write-only caching are represented correctly in the model catalog
- Dual-publish `@clinebot/cli` mirror wrapper so existing users who installed via `npm i -g @clinebot/cli` continue receiving updates
- Fix response truncation for OpenAI Codex model responses
## 0.0.12
- Fix markdown rendering in the published binary: headers, inline code, blockquotes, bold, italic, and lists now render with proper syntax highlighting (tables were the only element working before)
- Add keyboard shortcuts for scrolling through the chat transcript (Page Up/Down, Home/End)
- Preserve typed input when selecting slash command skills instead of clearing the prompt
- Fix `--thinking none` being ignored when persisted reasoning settings existed, which caused DeepSeek API errors
- Fix terminal cleanup on exit so the summary prints cleanly
- Fix onboarding provider model resolution
- Hide ChatGPT subscription provider usage costs
- Handle file index prewarm timeouts gracefully instead of hanging
## 0.0.11
- Add `/skills` slash command for browsing and toggling available skills interactively
- System prompts from AI SDK are now passed via the dedicated `system` option instead of being embedded in message history
- Context compaction can now be triggered manually and runs more reliably
- Disable the search tool in yolo mode so the model uses bash for searching instead
- Fix `submit_and_exit` completion policy not being wired through to the runtime
- Fix resumed sessions losing tool results when an abort interrupted tool execution mid-turn
- Fix interactive sessions becoming unusable after aborting a running turn
- Fix strict JSON schema mode rejecting valid tool schemas with unions, optional fields, and nullable types
- Fix stray log output appearing over the TUI when the log file fallback wrote directly to the stderr file descriptor, bypassing the TUI's stdio capture
- Refresh the built-in model catalog with the latest available models and pricing
## 0.0.10
- Improve local provider onboarding: setting up Ollama, LM Studio, or other local providers now prompts for the endpoint URL directly, supports typing a model ID manually when the provider returns no models, and correctly discovers models from your saved endpoint
- Ctrl+C no longer cancels a running turn -- it now clears the input field or exits the CLI, matching standard terminal behavior. Use Escape to cancel a running turn instead
- Thinking level chosen in the model picker now persists across CLI restarts instead of resetting to off
- The context bar now shows visible progress as tokens are used, instead of appearing empty on some terminal themes
- The status bar token count now shows actual context window usage instead of over-counting across multiple model calls in a turn
- Resuming a saved session now correctly displays the accumulated cost
- Sessions are now saved to disk after each assistant response, so conversation progress survives crashes or unexpected exits
- Auto-compaction now runs inline during model requests, keeping long conversations within the context window automatically
- The home screen robot now follows the cursor while you type
- Hub websocket connections now automatically reconnect after going idle, so sessions no longer silently lose their connection to the hub daemon
- MCP stdio servers on Windows no longer spawn visible console windows
- Tool input schemas containing `allOf` clauses are now handled correctly instead of being rejected
- Login now uses device auth exclusively
- Fix chat input and chat view text losing its indent on wrapped lines
## 0.0.9
- Fix stray text appearing over the TUI when background operations (like hub restart messages) write directly to stdout/stderr during interactive sessions
- Fix hub connection recovery: when a newer CLI instance restarts the shared hub daemon, already-running CLI sessions now automatically reconnect to the new hub endpoint instead of failing with transport errors
## 0.0.8
- Fix crash when pressing Escape to cancel a running turn
- Add plugin and SDK tool toggles to the settings panel
- Add `@cline/sdk` as a user-facing alias for `@cline/core`
- Improve hub recovery with better error handling, logging, and recovery timeouts
- Show session summary (ID, model, cost, resume command) on exit
- Fix OAuth browser-launch failure
- Fix compact no-op being reported indistinctly
- Fix CLI history resume being non-transactional (could leave blank UI or corrupt session on disk)
- Fix cross-client session history not loading Code/VS Code sessions, and fix interactive turn status showing stale state
- Fix configuration file paths for hooks and rules (now resolve from `~/.cline/hooks` and `~/.cline/rules`)
- Fix Telegram connector: honor `--no-tools` flag, lock tool-disabled mode across state changes, post replies as raw text to avoid markdown parse failures, add `/help` and `/start` commands
- Clean up CLI program description and compact slash command descriptions
- Clean up CLI flags
## 0.0.7
- Fix graceful recovery when the model returns malformed tool call inputs, preventing crashes mid-conversation
- Add settings toggles for core skills (enable/disable individual skills from the settings panel)
- Secure the local hub daemon with a discovery auth token, preventing unauthorized local access
- Fix auto-approve tool policies being incorrectly reset after session restore
- Fix npm wrapper detection for auto updates, so self-update works when the CLI is invoked through npm/npx shims
- Improve fork session UX with clearer prompts and smoother flow
- Fix manual thinking budget not being applied when using Anthropic models directly
- Improve account onboarding flow with better error messages and step sequencing
- Add enable/disable controls for individual tools and plugins
- Fix abort handling so the public run promise resolves correctly when a run is cancelled
- Fix markdown token styling in chat output
- Fix chat auto-scrolling to bottom on message submit
- Fix hub tool capabilities being routed to the wrong session
- Revert loading extension-created sessions from history (was causing issues)
## 0.0.6
- Add checkpoint restore: press Esc twice or type `/undo` to rewind to a previous checkpoint, with options to restore chat only or chat + workspace
- Fix clipboard: fall back to system clipboard (pbcopy, PowerShell, wl-copy, xclip) when OSC 52 fails, fixing copy for longer text selections
- Fix prompt focus: restore focus to the prompt input after dialogs close, preventing the input from becoming unresponsive after using `/settings`
## 0.0.5
- The input field has been completely redesigned -- the old bordered box is replaced with a clean chevron-prompt style that adapts its background color to any terminal theme using perceptual OKLAB color math. Light terminals are fully supported now.
- Pasting 5+ lines into the input shows a compact preview marker instead of flooding the textarea. The full content is still submitted.
- Arrow-key history navigation respects cursor position so you don't lose your place when scrolling through previous prompts.
- The TUI renders immediately instead of blocking while the hub daemon boots. Hub readiness and session hydration happen in the background.
- Listing previous sessions no longer hydrates every full session, making `cline history` and the history picker snappy even with hundreds of sessions.
- Updating the CLI no longer leaves you connected to a stale hub daemon. Incompatible versions are detected and replaced automatically, eliminating the "Unsupported hub schedule command" class of errors.
- Schedules can now trigger on external events (webhooks, GitHub events, plugin-emitted signals) in addition to cron intervals, with deduplication, filtering, and retry policies.
- Plugins can register automation event types that feed into the scheduling system, enabling custom triggers from any source.
- Resuming a session automatically picks up any in-flight team runs without needing to remember or pass `--team-name`.
- `providers.json` (which stores API keys and OAuth tokens) is now written with 0600 permissions, preventing other processes on the machine from reading it.
- Models that emit `command` or `cmd` instead of `commands` (or `paths` instead of `path`) no longer fail. Common aliases are normalized before execution.
## 0.0.4
- Fix compiled binary spawning infinite hub daemon recursion loop
## 0.0.3
- Rewritten TUI from Ink to OpenTUI with streaming markdown, syntax-highlighted diffs, scrollable chat, and mouse support
- Dialog system for model picker, tool approval, settings browser, session history, and onboarding
- Interactive setup wizards: `cline connect`, `cline schedule`, `cline mcp`
- Plan/Act mode toggle with system prompt and tool rebuilding on switch
- Input autocomplete for slash commands and file mentions
- Message queuing and steer messages during running turns
- Platform-specific compiled binaries for macOS, Linux, and Windows (arm64 and x64)
- npm trusted publishing via GitHub Actions OIDC
-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,
};
-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 "$@"
-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;
}
-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",
},
},
});
});
});
-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);
});
});
});
-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,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();
});
});
-408
View File
@@ -1,408 +0,0 @@
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"]);
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()) {
const connector = await getConnector(entry.name);
if (!connector) {
continue;
}
if (!connector.stopAll) {
continue;
}
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, failedProcesses, 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}`,
);
return failedProcesses === 0 ? 0 : 1;
}
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) {
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);
}
io.writeln(
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} 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;
}
export async function runConnectAdapter(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (connector) {
const delegated = await tryDelegateToHub(connector, passthroughArgs, io);
if (delegated !== undefined) {
return delegated;
}
}
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
}
export function formatAdapterList(): string {
const lines: string[] = [];
for (const connector of listConnectors()) {
lines.push(` ${connector.name.padEnd(12)} ${connector.description}`);
}
return lines.join("\n");
}
-208
View File
@@ -1,208 +0,0 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { arch, platform, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"CLINE_DIR",
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_WRAPPER_PATH",
] as const;
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
);
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe("runDashboardCommand", () => {
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
const output: string[] = [];
const errors: string[] = [];
const opened: string[] = [];
const stop = vi.fn();
let observedEnv:
| {
workspaceRoot: string | undefined;
clineDir: string | undefined;
clineDataDir: string | undefined;
providerSettingsPath: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
roomSecret: string | undefined;
webviewDistDir: string | undefined;
}
| undefined;
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
io: {
writeln: (text) => output.push(text ?? ""),
writeErr: (text) => errors.push(text),
},
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
clineDir: process.env.CLINE_DIR,
clineDataDir: process.env.CLINE_DATA_DIR,
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
roomSecret: process.env.ROOM_SECRET,
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
};
return {
listenUrl: "http://127.0.0.1:9090/",
publicUrl: "http://127.0.0.1:9090",
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
hubUrl: "ws://127.0.0.1:25463/hub",
stop,
};
},
openUrl: async (url) => {
opened.push(url);
},
waitForShutdown: async (server) => {
await server.stop();
},
});
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
clineDir: "/tmp/cline-config",
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
providerSettingsPath: join(
resolve("sdk", ".cline-dashboard-data"),
"settings",
"providers.json",
),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
roomSecret: "secret",
webviewDistDir,
});
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
expect(stop).toHaveBeenCalledTimes(1);
expect(output.join("\n")).toContain("Cline dashboard listening at");
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
expect(errors).toEqual([]);
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
});
it("honors --no-open behavior", async () => {
const openUrl = vi.fn();
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => ({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
}),
openUrl,
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(openUrl).not.toHaveBeenCalled();
});
it("finds webview assets from the published wrapper package layout", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
const platformName = platform() === "win32" ? "windows" : platform();
const webviewDistDir = join(
root,
"node_modules",
"cline",
"node_modules",
"@cline",
`cli-${platformName}-${arch()}`,
"cline-hub",
"webview",
);
mkdirSync(join(wrapperPath, ".."), { recursive: true });
mkdirSync(webviewDistDir, { recursive: true });
process.env.CLINE_WRAPPER_PATH = wrapperPath;
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
let observedWebviewDistDir: string | undefined;
const exitCode = await runDashboardCommand({
openBrowser: false,
io: {
writeln: () => {},
writeErr: () => {},
},
startServer: async () => {
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
return {
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(),
};
},
waitForShutdown: async () => {},
});
expect(exitCode).toBe(0);
expect(observedWebviewDistDir).toBe(webviewDistDir);
});
it("settles shutdown when server stop rejects", async () => {
const shutdown = waitForProcessShutdown({
listenUrl: "http://127.0.0.1:8787/",
publicUrl: "http://127.0.0.1:8787",
inviteUrl: "http://127.0.0.1:8787",
stop: vi.fn(async () => {
throw new Error("stop failed");
}),
});
process.emit("SIGINT", "SIGINT");
await expect(shutdown).rejects.toThrow("stop failed");
});
});
-215
View File
@@ -1,215 +0,0 @@
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { configureSandboxEnvironment } from "../utils/helpers";
import open from "../utils/open";
import { c } from "../utils/output";
export interface DashboardServerHandle {
listenUrl: string;
publicUrl: string;
inviteUrl: string;
hubUrl?: string;
stop: () => void | Promise<void>;
}
interface DashboardCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
export interface RunDashboardCommandOptions {
configDir?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
roomSecret?: string;
openBrowser?: boolean;
io: DashboardCommandIo;
startServer?: () => Promise<DashboardServerHandle>;
openUrl?: (url: string) => Promise<void>;
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
}
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value !== undefined) {
process.env[name] = value;
}
return () => {
if (previous === undefined) {
delete process.env[name];
} else {
process.env[name] = previous;
}
};
}
const SANDBOX_ENV_KEYS = [
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
] as const;
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const restore = [
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
configureSandboxEnvironment({
enabled: true,
cwd,
explicitDir: options.dataDir,
});
}
try {
return await fn();
} finally {
for (let i = restore.length - 1; i >= 0; i--) {
restore[i]?.();
}
}
}
function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
}
const moduleDir = dirname(fileURLToPath(import.meta.url));
const candidates = [
...resolveInstalledPlatformPackageWebviewCandidates(),
// Source checkout: apps/cli/src/commands/dashboard.ts
join(moduleDir, "../../../cline-hub/dist/webview"),
// Node bundle: apps/cli/dist/index.js
join(moduleDir, "cline-hub/webview"),
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
join(dirname(process.execPath), "../cline-hub/webview"),
];
return candidates.find((candidate) => existsSync(candidate));
}
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
const packageName = resolvePlatformPackageName();
const starts = [
process.env.CLINE_WRAPPER_PATH
? dirname(process.env.CLINE_WRAPPER_PATH)
: undefined,
dirname(process.execPath),
].filter((value): value is string => !!value?.trim());
const candidates: string[] = [];
for (const start of starts) {
let current = start;
for (;;) {
candidates.push(
join(current, "node_modules", packageName, "cline-hub/webview"),
);
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
}
return candidates;
}
function resolvePlatformPackageName(): string {
const platformName = platform() === "win32" ? "windows" : platform();
return `@cline/cli-${platformName}-${arch()}`;
}
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
return await startClineHubDashboardServer();
}
async function openDefaultUrl(url: string): Promise<void> {
await open(url, { wait: false });
}
export function waitForProcessShutdown(
server: DashboardServerHandle,
): Promise<void> {
return new Promise<void>((resolveShutdown, rejectShutdown) => {
let settled = false;
const cleanup = () => {
process.off("SIGINT", handleSignal);
process.off("SIGTERM", handleSignal);
};
const stop = async () => {
if (settled) return;
settled = true;
cleanup();
try {
await server.stop();
resolveShutdown();
} catch (error) {
rejectShutdown(error);
}
};
function handleSignal() {
void stop();
}
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
});
}
export async function runDashboardCommand(
options: RunDashboardCommandOptions,
): Promise<number> {
try {
const server = await withDashboardEnvironment(options, () =>
(options.startServer ?? startDefaultDashboardServer)(),
);
const dashboardUrl =
server.inviteUrl || server.publicUrl || server.listenUrl;
options.io.writeln(
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
);
if (server.hubUrl) {
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
}
if (options.openBrowser !== false) {
try {
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io.writeErr(`Failed to open browser: ${message}`);
}
}
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
return 0;
} catch (error) {
options.io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
-727
View File
@@ -1,727 +0,0 @@
import {
appendFileSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
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 {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockReadSupersededHubDiscovery,
mockProbeHubServer,
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
mockListSupervisedConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"owners",
"hub-owner.json",
),
})),
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", () => ({
spawnSync: mockSpawnSync,
}));
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
isProcessRunning: vi.fn(() => false),
}));
vi.mock("./connect", () => ({
stopAllConnectors: mockStopAllConnectors,
}));
vi.mock("./connect-via-hub", () => ({
listSupervisedConnectorsViaHub: mockListSupervisedConnectors,
}));
import { __test__, createDoctorCommand, runDoctorCommand } from "./doctor";
describe("runDoctorCommand", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not report hub processes as stale cli processes", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
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[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
stdout: [
"50174 /Users/example/.bun/bin/bun /Users/example/dev/apps/cli/src/index.ts hub start --cwd /workspace",
"50190 /Users/example/.bun/bin/bun /Users/example/dev/apps/cli/src/index.ts hey",
].join("\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(output).toHaveLength(1);
expect(JSON.parse(output[0] || "")).toMatchObject(
process.platform === "win32"
? {
listeningPids: [],
hubStartupLocks: [],
staleCliPids: [],
staleSidecarPids: [],
}
: {
listeningPids: [50174],
hubStartupLocks: [],
staleCliPids: [50190],
staleSidecarPids: [],
},
);
});
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);
const discoveryPath = path.join(cwd, ".hub-discovery.json");
mockResolveSharedHubOwnerContext.mockReturnValue({
ownerId: "hub-owner",
discoveryPath,
});
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50000,
});
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const startupLockDir = `${discoveryPath}.lock`;
writeFileSync(
discoveryPath,
JSON.stringify({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50000,
}),
"utf8",
);
mkdirSync(startupLockDir, { recursive: true });
writeFileSync(
path.join(startupLockDir, "owner.json"),
JSON.stringify({
pid: process.pid,
acquiredAt: new Date().toISOString(),
}),
"utf8",
);
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(output).toHaveLength(1);
expect(JSON.parse(output[0] || "")).toMatchObject({
killed: {
hubListeners: 0,
cliProcesses: 0,
sidecarProcesses: 0,
hubStartupLocks: 1,
hubDiscovery: 1,
},
after: {
hubHealthy: false,
listeningPids: [],
hubStartupLocks: [],
staleSidecarPids: [],
},
});
expect(mockClearHubDiscovery).toHaveBeenCalledWith(discoveryPath);
});
it("doctor --fix stops connector adapters and reports counts in JSON", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue(undefined);
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(mockStopAllConnectors).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toMatchObject({
killed: {
connectorProcesses: 2,
connectorSessions: 5,
},
});
});
it("doctor --fix kills stale code sidecar processes", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue(undefined);
mockProbeHubServer.mockResolvedValue(undefined);
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
if (
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
stdout:
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
};
}
return { status: 1, stdout: "" };
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true, fix: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(killSpy).toHaveBeenCalledWith(60123, "SIGKILL");
expect(JSON.parse(output[0] || "")).toMatchObject({
before: {
staleSidecarPids: [60123],
},
killed: {
sidecarProcesses: 1,
},
});
killSpy.mockRestore();
});
});
describe("createDoctorCommand log subcommand", () => {
const tempDirs: string[] = [];
const commandName = getCliBuildInfo().name;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("opens the log file for doctor log", async () => {
const dataDir = mkdtempSync(
path.join(os.tmpdir(), `${commandName}-doctor-log-test-`),
);
tempDirs.push(dataDir);
mockResolveClineDataDir.mockReturnValue(dataDir);
mockEnsureFileExists.mockImplementation((filePath: string) => {
mkdirSync(path.dirname(filePath), { recursive: true });
appendFileSync(filePath, "");
});
const opened: string[] = [];
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createDoctorCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
{
openPath: async (target) => {
opened.push(target);
},
},
);
await cmd.parseAsync(["log"], { from: "user" });
const expectedPath = path.join(dataDir, "logs", `${commandName}.log`);
expect(exitCode).toBe(0);
expect(errors).toHaveLength(0);
expect(opened).toEqual([expectedPath]);
expect(output).toEqual([`Opening logs stored at ${expectedPath}`]);
expect(existsSync(expectedPath)).toBe(true);
});
it("returns an error if opening log file fails", async () => {
const dataDir = mkdtempSync(
path.join(os.tmpdir(), `${commandName}-doctor-log-test-`),
);
tempDirs.push(dataDir);
mockResolveClineDataDir.mockReturnValue(dataDir);
const errors: string[] = [];
let exitCode = 0;
const cmd = createDoctorCommand(
{
writeln: () => {},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
{
openPath: async () => {
throw new Error("open failed");
},
},
);
await cmd.parseAsync(["log"], { from: "user" });
expect(exitCode).toBe(1);
expect(errors[0]).toContain("failed to open log file");
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.",
);
});
});
@@ -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),
);
});
}
-416
View File
@@ -1,416 +0,0 @@
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,
};
}),
};
});
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
expect(
buildMcpInstallDefaults({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
type: "stdio",
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
});
});
it("builds remote wizard defaults and normalizes http transport", () => {
expect(
buildMcpInstallDefaults({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
}),
).toEqual({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("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({
name: "docs",
transport: "streamable-http",
targetArgs: ["https://example.com/mcp"],
}),
).toEqual({
name: "docs",
type: "streamableHttp",
url: "https://example.com/mcp",
});
});
it("builds SSE wizard defaults", () => {
expect(
buildMcpInstallDefaults({
name: "events",
transport: "sse",
targetArgs: ["https://example.com/sse"],
}),
).toEqual({
name: "events",
type: "sse",
url: "https://example.com/sse",
});
});
it("rejects missing stdio command and invalid remote URL", () => {
expect(() =>
buildMcpInstallDefaults({
name: "fs",
}),
).toThrow(/requires a command/);
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["not-a-url"],
}),
).toThrow(/Invalid MCP server URL/);
});
it("rejects remote URL schemes other than http and https", () => {
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["file:///etc/passwd"],
}),
).toThrow(/only http and https are supported/);
});
it("builds direct stdio installs without shell-joining args", () => {
expect(
buildMcpInstallTransport({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
transport: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
},
warnings: [],
});
});
it("builds direct remote installs with headers and placeholder warnings", () => {
expect(
buildMcpInstallTransport({
name: "docs",
transport: "http",
headers: ["Authorization: Bearer <token>"],
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
}),
).toEqual({
name: "docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer <token>",
"X-Extra": "yes",
},
},
warnings: [
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
],
});
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: true,
runWizard,
io: { writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(runWizard).toHaveBeenCalledWith({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("requires a TTY because it opens the wizard", async () => {
const writeErr = vi.fn();
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: false,
runWizard,
io: { writeErr },
});
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("checks for TTY before validating wizard install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
isTty: false,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("installs directly with --yes without requiring a TTY", async () => {
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
});
it("prints direct install JSON with --yes --json", async () => {
const writeln = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
targetArgs: ["node", "server.js"],
isTty: false,
yes: true,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
name: "fs",
status: "installed",
transport: {
type: "stdio",
command: "node",
args: ["server.js"],
},
});
});
});
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);
});
});
-154
View File
@@ -1,154 +0,0 @@
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 {
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 quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
}
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
}
export function buildMcpInstallDefaults(options: {
name: string;
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const { name, transport } = buildCoreMcpInstallTransport(options);
if (transport.type === "stdio") {
return {
name,
type: transport.type,
command: [transport.command, ...(transport.args ?? [])]
.map(quoteCommandArg)
.join(" "),
};
}
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,
};
}
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
const { runMcpWizard } = await import("../wizards/mcp");
return runMcpWizard({
initialAction: "add",
addDefaults: defaults,
exitAfterInitialAction: true,
});
}
export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
if (options.yes) {
const result = installMcpServerDirect(options);
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
for (const warning of result.warnings) {
options.io?.writeErr(warning);
}
}
return 0;
}
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
}
const defaults = buildMcpInstallDefaults(options);
return await (options.runWizard ?? runPrefilledWizard)(defaults);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
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
-200
View File
@@ -1,200 +0,0 @@
import {
installPlugin,
type PluginInstallOptions,
type PluginInstallResult,
type PluginMcpOAuthCandidate,
type PluginUninstallOptions,
uninstallPlugin,
} from "@cline/core";
export type {
PluginInstallOptions,
PluginInstallResult,
PluginMcpOAuthCandidate,
} from "@cline/core";
export {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
} from "@cline/core";
export interface PluginInstallMcpOAuthOptions {
interactive?: boolean;
selectCandidates?: (
candidates: PluginMcpOAuthCandidate[],
) => Promise<PluginMcpOAuthCandidate[]>;
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
}
export interface PluginInstallIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
type PluginInstallCommandOptions = PluginInstallOptions & {
json?: boolean;
io?: PluginInstallIo;
mcpOAuth?: PluginInstallMcpOAuthOptions;
};
function serializePluginInstallResult(
result: PluginInstallResult,
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
return {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
};
}
function isInteractivePluginInstall(
options: PluginInstallCommandOptions,
): boolean {
return (
options.mcpOAuth?.interactive ??
(process.stdin.isTTY && process.stdout.isTTY)
);
}
async function selectMcpOAuthCandidatesWithClack(
candidates: PluginMcpOAuthCandidate[],
): Promise<PluginMcpOAuthCandidate[]> {
const p = await import("@clack/prompts");
const action = await p.select({
message: "Authorize plugin MCP servers now?",
options: [
{
value: "all",
label: "Authorize all",
hint: "open browser authorization for each server",
},
{
value: "choose",
label: "Choose servers",
hint: "select which servers to authorize",
},
{
value: "skip",
label: "Skip",
},
],
});
if (p.isCancel(action) || action === "skip") {
return [];
}
if (action === "all") {
return candidates;
}
const selectedNames = await p.multiselect({
message: "Select MCP servers to authorize",
options: candidates.map((candidate) => ({
value: candidate.name,
label: candidate.name,
hint: `${candidate.transportType} [${candidate.pluginName}]`,
})),
required: false,
});
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
return [];
}
const selected = new Set(selectedNames);
return candidates.filter((candidate) => selected.has(candidate.name));
}
async function authorizeMcpOAuthCandidate(
candidate: PluginMcpOAuthCandidate,
): Promise<void> {
const { authorizeMcpServerOAuthWithBrowser } = await import(
"../wizards/mcp/oauth"
);
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
throwOnError: true,
});
}
async function runPluginMcpOAuthFollowup(
candidates: PluginMcpOAuthCandidate[],
options: PluginInstallCommandOptions,
): Promise<void> {
if (candidates.length === 0) {
return;
}
if (!isInteractivePluginInstall(options)) {
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
for (const candidate of candidates) {
options.io?.writeln(
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
);
}
options.io?.writeln(
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
);
return;
}
const selected =
options.mcpOAuth?.selectCandidates !== undefined
? await options.mcpOAuth.selectCandidates(candidates)
: await selectMcpOAuthCandidatesWithClack(candidates);
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
for (const candidate of selected) {
try {
await authorize(candidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
);
}
}
}
export async function runPluginInstallCommand(
options: PluginInstallCommandOptions,
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(
JSON.stringify(serializePluginInstallResult(result)),
);
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
for (const failure of result.mcpSyncFailures) {
options.io?.writeErr(
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
}
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
export async function runPluginUninstallCommand(
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
): Promise<number> {
try {
const result = await uninstallPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Uninstalled plugin ${result.name}`);
options.io?.writeln(` Removed: ${result.installPath}`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
-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})`,
);
});
});
@@ -1,52 +0,0 @@
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
interface ProviderSettingsReader {
getLastUsedProviderSettings(): ProviderSettings | undefined;
getProviderSettings(providerId: string): ProviderSettings | undefined;
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveScheduleModelSelection(
options: {
provider?: string;
model?: string;
},
providerSettingsManager?: ProviderSettingsReader,
): { provider: string; model: string } {
const explicitProvider = trimToUndefined(options.provider);
const explicitModel = trimToUndefined(options.model);
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, model: explicitModel };
}
const manager = providerSettingsManager ?? new ProviderSettingsManager();
const lastUsedSettings = manager.getLastUsedProviderSettings();
const provider =
explicitProvider ??
trimToUndefined(lastUsedSettings?.provider) ??
DEFAULT_SCHEDULE_PROVIDER;
const selectedProviderSettings = explicitProvider
? manager.getProviderSettings(provider)
: lastUsedSettings;
const model =
explicitModel ??
trimToUndefined(selectedProviderSettings?.model) ??
(provider === DEFAULT_SCHEDULE_PROVIDER
? CLINE_DEFAULT_MODEL_ID
: undefined);
if (!model) {
throw new Error(
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
);
}
return { provider, model };
}
-88
View File
@@ -1,88 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"add",
"owner/repo",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
);
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
});
});
-160
View File
@@ -1,160 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
export interface SkillCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
* Build the argument list passed to `npx`, injecting `--agent cline` for
* install-style subcommands unless the user already targeted an agent.
*/
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
!hasAgentFlag(args)
) {
args.push("--agent", "cline");
}
return ["-y", SKILLS_PACKAGE, ...args];
}
function resolveExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
/**
* Forward all arguments to the open skills CLI via `npx skills`.
*
* Returns the child process exit code, or 1 if npx is unavailable or fails to
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
* pass straight through to the user's terminal.
*/
export async function runSkillCommand(
userArgs: readonly string[],
io: SkillCommandIo,
): Promise<number> {
const args = buildSkillsArgs(userArgs);
const isWindows = process.platform === "win32";
const options: SpawnOptions = {
stdio: "inherit",
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(isWindows ? { shell: true } : {}),
};
return new Promise<number>((resolve) => {
const child = spawn("npx", args, options);
const forward = (signal: NodeJS.Signals) => {
child.kill(signal);
};
const handleSigint = () => forward("SIGINT");
const handleSigterm = () => forward("SIGTERM");
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
const cleanup = () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
child.once("error", (error: NodeJS.ErrnoException) => {
cleanup();
if (error.code === "ENOENT") {
io.writeErr(
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
);
} else {
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
}
resolve(1);
});
child.once("close", (code, signal) => {
cleanup();
resolve(resolveExitCode(code, signal));
});
});
}
-382
View File
@@ -1,382 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: mockSpawn,
};
});
import {
applyDeferredUpdate,
autoUpdateOnStartup,
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createFile(path: string): string {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
return path;
}
function createTempFile(pathSuffix: string): string {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
return createFile(join(root, pathSuffix));
}
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("detects npm installs from the wrapper path passed to the compiled binary", () => {
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag latest",
});
});
it("uses the nightly tag when the current CLI version is nightly", () => {
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
packageManager: PackageManager.NPM,
packageName: "cline",
updateCommand: "npm update -g cline --tag nightly",
});
});
it("detects bun global installs from the resolved install path", () => {
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
// and realpathSync resolves through the symlink before detection runs.
const wrapperPath = createTempFile(
".bun/install/global/node_modules/cline/bin/cline",
);
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.BUN,
packageName: "cline",
updateCommand: "bun add -g cline@latest",
});
});
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
delete process.env.CLINE_WRAPPER_PATH;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.UNKNOWN,
packageName: "cline",
});
});
});
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("skips startup auto update when disabled globally", () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.IS_DEV;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("should not fetch"));
autoUpdateOnStartup();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("still lets manual update checks run when startup auto update is disabled", async () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.0" }),
} as Response);
await checkForUpdates({ includeKanban: false });
expect(fetchSpy).toHaveBeenCalled();
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("deferred auto update", () => {
afterEach(() => {
mockSpawn.mockReset();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("does nothing when no update was recorded", async () => {
expect(await applyDeferredUpdate(undefined)).toBe("none");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("starts the detached install when no hub is discoverable", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
process.env.CLINE_BUILD_ENV = "production";
process.env.CLINE_HUB_DISCOVERY_PATH = join(root, "production.json");
const unref = vi.fn();
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
const outcome = await applyDeferredUpdate({
command: "npm update -g cline --tag latest --min-release-age=0",
});
expect(outcome).toBe("started");
expect(mockSpawn).toHaveBeenCalledWith(
"npm update -g cline --tag latest --min-release-age=0",
expect.objectContaining({
detached: true,
shell: true,
stdio: "ignore",
}),
);
expect(unref).toHaveBeenCalled();
});
it("defers while another cli client is attached to the hub", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
const discoveryPath = join(root, "production.json");
process.env.CLINE_BUILD_ENV = "production";
process.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
const {
createLocalHubScheduleRuntimeHandlers,
NodeHubClient,
startHubWebSocketServer,
} = await import("@cline/core");
const server = await startHubWebSocketServer({
host: "127.0.0.1",
port: 0,
owner: { ownerId: "update-test", discoveryPath },
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const cliClient = new NodeHubClient({
url: server.url,
authToken: server.authToken,
clientType: "cli",
displayName: "fake attached cli",
});
try {
await cliClient.command("client.list", {});
expect(await applyDeferredUpdate({ command: "echo update" })).toBe(
"deferred",
);
expect(mockSpawn).not.toHaveBeenCalled();
await cliClient.dispose();
const unref = vi.fn();
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
// The hub unregisters the client when its socket closes; poll
// briefly rather than assuming the close is processed instantly.
let outcome = "deferred";
const deadline = Date.now() + 3_000;
while (outcome === "deferred" && Date.now() < deadline) {
outcome = await applyDeferredUpdate({ command: "echo update" });
}
expect(outcome).toBe("started");
} finally {
await cliClient.dispose().catch(() => undefined);
await server.close();
}
}, 15_000);
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
withMinimumReleaseAgeBypass(
"npm update -g cline --tag latest",
PackageManager.NPM,
).command,
).toBe("npm update -g cline --tag latest --min-release-age=0");
expect(
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
.command,
).toBe("bun add -g cline@latest --minimum-release-age=0");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).command,
).toBe("yarn global add cline@latest");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).env?.YARN_NPM_MINIMAL_AGE_GATE,
).toBe("0");
expect(
withMinimumReleaseAgeBypass(
"pnpm add -g cline@latest",
PackageManager.PNPM,
).env?.pnpm_config_minimum_release_age,
).toBe("0");
});
});
@@ -1,485 +0,0 @@
import type { ConnectSlackOptions } from "@cline/shared";
import { type Message, ThreadImpl } from "chat";
import { describe, expect, it } from "vitest";
import { __test__, slackConnector } from "./slack";
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
(
slackConnector as unknown as {
parseArgs(rawArgs: string[]): ConnectSlackOptions;
}
).parseArgs(rawArgs);
describe("slack binding lookup", () => {
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
it("infers Slack webhook mode from a base URL", () => {
expect(__test__.inferSlackConnectionMode("https://example.test")).toBe(
"webhook",
);
expect(__test__.inferSlackConnectionMode(" ")).toBe("socket");
expect(__test__.inferSlackConnectionMode(undefined)).toBe("socket");
});
it("uses webhook mode when Slack args include a base URL", () => {
const options = parseSlackArgs([
"--bot-token",
"xoxb-token",
"--signing-secret",
"secret",
"--app-token",
"xapp-ignored",
"--base-url",
"https://example.test",
]);
expect(options.connectionMode).toBe("webhook");
expect(options.baseUrl).toBe("https://example.test");
expect(options.signingSecret).toBe("secret");
expect(options.appToken).toBeUndefined();
});
it("uses socket mode when Slack args omit a base URL", () => {
const previousBaseUrl = process.env.BASE_URL;
delete process.env.BASE_URL;
let options: ConnectSlackOptions;
try {
options = parseSlackArgs([
"--bot-token",
"xoxb-token",
"--app-token",
"xapp-token",
]);
} finally {
if (previousBaseUrl === undefined) {
delete process.env.BASE_URL;
} else {
process.env.BASE_URL = previousBaseUrl;
}
}
expect(options.connectionMode).toBe("socket");
expect(options.baseUrl).toBeUndefined();
expect(options.appToken).toBe("xapp-token");
});
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
channelId: "slack:C123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "new_thread_id",
channelId: "slack:C123",
isDM: true,
},
);
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "slack:C123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
});
it("prefers an exact thread id match over a channel fallback", () => {
const result = __test__.findBindingForThread(
{
current_thread_id: {
channelId: "slack:C123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-2",
state: { sessionId: "sess-2", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
legacy_thread_id: {
channelId: "slack:C123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "current_thread_id",
channelId: "slack:C123",
isDM: false,
},
);
expect(result?.key).toBe("current_thread_id");
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
channelId: "slack:C123",
isDM: false,
participantKey,
participantLabel: "alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
participantKey,
participantLabel: "alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "other_thread_id",
channelId: "slack:C999",
isDM: false,
participantKey,
},
);
expect(result).toBeUndefined();
});
it("builds Slack participant keys with a team scope", () => {
expect(__test__.buildSlackParticipantKey("T123", "U123")).toBe(
"slack:team:T123:user:U123",
);
});
it("requires team context before resolving a Slack participant key", () => {
expect(
__test__.resolveSlackParticipant(
{ user: "U123", username: "alice" },
undefined,
),
).toBeUndefined();
expect(
__test__.resolveSlackParticipant(
{ team_id: "T123", user: "U123", username: "alice" },
"T123",
),
).toEqual({
key: "slack:team:T123:user:U123",
label: "alice",
});
});
it("normalizes direct-message channels even when Slack omits im channel_type", () => {
expect(
__test__.normalizeSlackMessageEventChannelType({
channel: "D123",
channel_type: "app_home",
ts: "123.456",
}),
).toEqual({
channel: "D123",
channel_type: "im",
ts: "123.456",
});
expect(
__test__.normalizeSlackMessageEventChannelType({
channel: "D123",
ts: "123.456",
}),
).toEqual({
channel: "D123",
channel_type: "im",
ts: "123.456",
});
});
it("leaves non-DM Slack message events unchanged", () => {
const channelEvent = {
channel: "C123",
channel_type: "channel",
ts: "123.456",
};
expect(__test__.normalizeSlackMessageEventChannelType(channelEvent)).toBe(
channelEvent,
);
});
it("normalizes top-level channel mentions to the original Slack post thread", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> help",
ts: "1710000000.123456",
type: "app_mention",
user: "U123",
},
} as Message;
const normalized = __test__.resolveSlackChannelMentionThread(
original,
message,
);
expect(normalized.id).toBe("slack:C123:1710000000.123456");
expect(normalized.channelId).toBe("slack:C123");
expect(normalized.isDM).toBe(false);
});
it("uses Slack thread_ts instead of reply ts for in-thread mentions", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:1710000001.654321",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> follow up",
thread_ts: "1710000000.123456",
ts: "1710000001.654321",
type: "app_mention",
user: "U123",
},
} as Message;
const normalized = __test__.resolveSlackChannelMentionThread(
original,
message,
);
expect(normalized.id).toBe("slack:C123:1710000000.123456");
expect(normalized.channelId).toBe("slack:C123");
expect(normalized.isDM).toBe(false);
});
it("keeps Slack mention threads that already target the original post", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:C123",
id: "slack:C123:1710000000.123456",
isDM: false,
});
const message = {
raw: {
channel: "C123",
text: "<@U999> help",
ts: "1710000000.123456",
type: "app_mention",
user: "U123",
},
} as Message;
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
original,
);
});
it("does not rewrite Slack DM mention threads", () => {
const original = new ThreadImpl({
adapterName: "slack",
channelId: "slack:D123",
id: "slack:D123:",
isDM: true,
});
const message = {
raw: {
channel: "D123",
text: "help",
ts: "1710000000.123456",
type: "message",
user: "U123",
},
} as Message;
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
original,
);
});
it("routes Slack posts through the installation bot token for a team", async () => {
const calls: string[] = [];
const result = await __test__.withSlackTeamBotToken({
slack: {
getInstallation: async (teamId: string) => {
calls.push(`get:${teamId}`);
return { botToken: "xoxb-team-token" };
},
withBotToken: <T>(token: string, work: () => T): T => {
calls.push(`token:${token}`);
return work();
},
},
teamId: "T123",
work: async () => {
calls.push("work");
return "ok";
},
});
expect(result).toBe("ok");
expect(calls).toEqual(["get:T123", "token:xoxb-team-token", "work"]);
});
it("strips the leading bot mention from Slack message text", () => {
expect(
__test__.stripSlackBotMention("@U0B8E8H3U1F hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F> hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F|cline> hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention(" @U0B8E8H3U1F: hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention(
"@U0B8E8H3U1F @U0B8E8H3U1F hi",
"U0B8E8H3U1F",
),
).toBe("hi");
});
it("keeps Slack text that does not start with the bot mention", () => {
expect(
__test__.stripSlackBotMention("hi @U0B8E8H3U1F", "U0B8E8H3U1F"),
).toBe("hi @U0B8E8H3U1F");
expect(__test__.stripSlackBotMention("@U999999 hi", "U0B8E8H3U1F")).toBe(
"@U999999 hi",
);
expect(__test__.stripSlackBotMention("@cline hi", "U0B8E8H3U1F")).toBe(
"@cline hi",
);
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F hi", undefined)).toBe(
"@U0B8E8H3U1F hi",
);
});
it("keeps mentions of other Slack users whose id starts with the bot id", () => {
expect(__test__.stripSlackBotMention("@U1234 help", "U123")).toBe(
"@U1234 help",
);
expect(__test__.stripSlackBotMention("@U123 hi", "U123")).toBe("hi");
expect(__test__.stripSlackBotMention("<@U1234> help", "U123")).toBe(
"<@U1234> help",
);
expect(__test__.stripSlackBotMention("<@U1234|other> help", "U123")).toBe(
"<@U1234|other> help",
);
expect(
__test__.stripSlackBotMention("@U0B8E8H3U1FX hi", "U0B8E8H3U1F"),
).toBe("@U0B8E8H3U1FX hi");
expect(
__test__.stripSlackBotMention(
"@U0B8E8H3U1F @U0B8E8H3U1FX hi",
"U0B8E8H3U1F",
),
).toBe("@U0B8E8H3U1FX hi");
});
it("keeps a bare Slack bot mention so the turn is not dropped", () => {
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F", "U0B8E8H3U1F")).toBe(
"@U0B8E8H3U1F",
);
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F> ", "U0B8E8H3U1F"),
).toBe("<@U0B8E8H3U1F> ");
});
it("resolves the Slack bot user id from the adapter or event authorizations", () => {
expect(__test__.resolveSlackBotUserId({ botUserId: "U0B8E8H3U1F" })).toBe(
"U0B8E8H3U1F",
);
expect(
__test__.resolveSlackBotUserId(
{ botUserId: undefined },
{ authorizations: [{ user_id: "U0B8E8H3U1F" }] },
),
).toBe("U0B8E8H3U1F");
expect(
__test__.resolveSlackBotUserId({ botUserId: undefined }, { text: "hi" }),
).toBeUndefined();
});
it("detects Slack invalid_thread_ts errors", () => {
expect(
__test__.isSlackInvalidThreadTsError(
new Error("An API error occurred: invalid_thread_ts"),
),
).toBe(true);
expect(
__test__.isSlackInvalidThreadTsError(
new Error("An API error occurred: channel_not_found"),
),
).toBe(false);
});
});
describe("slack legacy connector state", () => {
it("stops a live connector recorded by a pre-claim state file (no claimId)", async () => {
const { spawn } = await import("node:child_process");
const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import(
"node:fs"
);
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const previousDataDir = process.env.CLINE_DATA_DIR;
const dataDir = mkdtempSync(join(tmpdir(), "slack-legacy-state-"));
process.env.CLINE_DATA_DIR = dataDir;
const child = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ stdio: "ignore" },
);
try {
const stateDir = join(dataDir, "connectors", "slack");
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, "mybot.json"),
JSON.stringify({
userName: "mybot",
connectionMode: "socket",
pid: child.pid,
// Unreachable on purpose: session cleanup falls back to
// local storage inside the isolated data dir.
rpcAddress: "ws://127.0.0.1:1/hub",
startedAt: new Date(0).toISOString(),
}),
"utf8",
);
const io = { writeln: () => {}, writeErr: () => {} };
const result = await slackConnector.stopInstance?.("mybot", io);
expect(result?.stoppedProcesses).toBe(1);
} finally {
child.kill("SIGKILL");
if (previousDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = previousDataDir;
}
rmSync(dataDir, { recursive: true, force: true });
}
});
});
@@ -1,790 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { handleConnectorUserTurn } from "../connector-host";
import { __test__, telegramConnector } from "./telegram";
const mocks = vi.hoisted(() => ({
spawnDetachedConnector: vi.fn(),
}));
vi.mock("../common", async (importOriginal) => ({
...(await importOriginal<typeof import("../common")>()),
spawnDetachedConnector: mocks.spawnDetachedConnector,
}));
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
(
telegramConnector as unknown as {
parseArgs(rawArgs: string[]): ConnectTelegramOptions;
}
).parseArgs(rawArgs);
const originalClineDataDir = process.env.CLINE_DATA_DIR;
const tempDataDirs: string[] = [];
beforeEach(() => {
vi.clearAllMocks();
mocks.spawnDetachedConnector.mockReturnValue(42);
});
function useTempClineDataDir(): string {
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
tempDataDirs.push(dataDir);
process.env.CLINE_DATA_DIR = dataDir;
return dataDir;
}
afterEach(() => {
vi.unstubAllGlobals();
if (originalClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
for (const dir of tempDataDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("telegramConnector", () => {
it("honors --no-tools", () => {
const options = parseTelegramArgs([
"--bot-username",
"test_bot",
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--no-tools",
]);
expect(options.enableTools).toBe(false);
});
it("enables tools by default", () => {
const options = parseTelegramArgs([
"--bot-username",
"test_bot",
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
]);
expect(options.enableTools).toBe(true);
});
it("builds an authorization hook from --allowed-user-id", () => {
const options = parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--allowed-user-id",
"1201547643",
]);
expect(options.hookCommand).toBe(
`jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:1201547643" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`,
);
});
it("rejects unsafe --allowed-user-id values", () => {
expect(() =>
parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--allowed-user-id",
"123; rm -rf /",
]),
).toThrow("digits only");
});
it("rejects mixing --allowed-user-id with --hook-command", () => {
expect(() =>
parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--allowed-user-id",
"1201547643",
"--hook-command",
"echo noop",
]),
).toThrow("either --allowed-user-id or --hook-command");
});
it("rejects mixing --allowed-user-id with the hook command env var", () => {
const originalHookCommand = process.env.CLINE_CONNECT_HOOK_COMMAND;
process.env.CLINE_CONNECT_HOOK_COMMAND = "echo noop";
try {
expect(() =>
parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--allowed-user-id",
"1201547643",
]),
).toThrow("either --allowed-user-id or --hook-command");
} finally {
if (originalHookCommand === undefined) {
delete process.env.CLINE_CONNECT_HOOK_COMMAND;
} else {
process.env.CLINE_CONNECT_HOOK_COMMAND = originalHookCommand;
}
}
});
it("does not require the bot username", () => {
const options = parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
]);
expect(options.botUsername).toBeUndefined();
expect(options.botToken).toBe("123:test");
});
it("normalizes an explicit bot username", () => {
const options = parseTelegramArgs([
"--bot-username",
" @test_bot ",
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
]);
expect(options.botUsername).toBe("test_bot");
});
it("validates a token before reporting its connector as already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
botId: "123",
pid: process.pid,
rpcAddress: "127.0.0.1:54321",
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () =>
Response.json({
ok: true,
result: { username: "resolved_bot" },
}),
);
vi.stubGlobal("fetch", fetchImpl);
const output: string[] = [];
const errors: string[] = [];
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
},
{
setPersistenceArgs: vi.fn(),
setPersistenceInstanceId: vi.fn(),
},
),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
expect(output).toEqual([
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
]);
});
it("reports the resolved bot username in persistence args", async () => {
const dataDir = useTempClineDataDir();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
}),
);
const setPersistenceArgs = vi.fn();
const setPersistenceInstanceId = vi.fn();
mocks.spawnDetachedConnector.mockImplementation(() => {
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
pid: process.pid,
}),
);
return process.pid;
});
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: () => {},
writeErr: () => {},
},
{ setPersistenceArgs, setPersistenceInstanceId },
),
).resolves.toBe(0);
expect(setPersistenceArgs).toHaveBeenCalledWith([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--bot-username",
"resolved_bot",
]);
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
});
});
describe("telegram bot username resolution", () => {
it("reads the public Telegram bot id from a token", () => {
expect(__test__.readTelegramBotId("123456:secret")).toBe("123456");
expect(__test__.readTelegramBotId("not-a-token")).toBeUndefined();
});
it("uses the configured username without calling Telegram", async () => {
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected fetch");
});
await expect(
__test__.resolveTelegramBotUsername(
{
botToken: "123:test",
botUsername: "@configured_bot",
cwd: "/tmp/work",
mode: "act",
interactive: true,
enableTools: true,
rpcAddress: "127.0.0.1:0",
},
fetchImpl,
),
).resolves.toBe("configured_bot");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("fetches the username from Telegram getMe when omitted", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
});
await expect(
__test__.fetchTelegramBotUsername("123:test", fetchImpl),
).resolves.toBe("resolved_bot");
expect(fetchImpl).toHaveBeenCalledWith(
"https://api.telegram.org/bot123:test/getMe",
);
});
it("surfaces Telegram getMe failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
ok: false,
description: "Unauthorized",
}),
{ status: 401, statusText: "Unauthorized" },
);
});
await expect(
__test__.fetchTelegramBotUsername("bad-token", fetchImpl),
).rejects.toThrow("Telegram getMe failed");
});
});
describe("telegram participant resolution", () => {
it("uses the stable numeric Telegram user id when username is also present", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: 1201547643,
username: "AraFatKatze",
first_name: "Ara",
},
},
});
expect(result).toEqual({
key: "telegram:id:1201547643",
label: "arafatkatze",
});
});
it("falls back to username when Telegram does not provide a numeric user id", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
username: "Alice",
},
},
});
expect(result).toEqual({
key: "telegram:user:alice",
label: "alice",
});
});
it("accepts string numeric user ids from raw Telegram payloads", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: "1201547643",
username: "arafatkatze",
},
},
});
expect(result?.key).toBe("telegram:id:1201547643");
});
});
describe("telegram binding lookup", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
channelId: "chat-123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "new_thread_id",
channelId: "chat-123",
isDM: true,
},
);
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "chat-123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
});
it("prefers an exact thread id match over a channel fallback", () => {
const result = __test__.findBindingForThread(
{
current_thread_id: {
channelId: "chat-123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-2",
state: { sessionId: "sess-2" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
legacy_thread_id: {
channelId: "chat-123",
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "current_thread_id",
channelId: "chat-123",
isDM: true,
},
);
expect(result?.key).toBe("current_thread_id");
expect(result?.binding.sessionId).toBe("sess-2");
});
it("does not reuse a binding by participant key across different chats", () => {
const result = __test__.findBindingForThread(
{
"telegram:user:alice": {
channelId: "chat-123",
isDM: true,
participantKey: "telegram:user:alice",
participantLabel: "alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
participantKey: "telegram:user:alice",
participantLabel: "alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "new_thread_id",
channelId: "chat-999",
isDM: true,
participantKey: "telegram:user:alice",
},
);
expect(result).toBeUndefined();
});
});
type SlashTestState = Record<string, unknown>;
function createSlashTestThread(input: {
id: string;
channelId: string;
isDM: boolean;
initialState?: SlashTestState;
}) {
let state: SlashTestState = { ...(input.initialState ?? {}) };
const posts: unknown[] = [];
let subscribed = false;
const thread = {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
get state() {
return Promise.resolve(state);
},
async setState(nextState: SlashTestState) {
state = { ...nextState };
},
async subscribe() {
subscribed = true;
},
async post(message: unknown) {
posts.push(message);
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
return sentMessage;
},
delete: async () => undefined,
};
return sentMessage;
},
async startTyping() {},
toJSON() {
return {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
state,
};
},
};
return {
thread,
posts,
getState: () => state,
isSubscribed: () => subscribed,
};
}
function slashTestStartRequest() {
return {
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
systemPrompt: "system",
provider: "cline",
model: "test-model",
mode: "act",
};
}
describe("telegram slash command delivery", () => {
it("receives bot_command updates intercepted by the telegram chat library", async () => {
// The Telegram Bot API tags any leading-slash message with a
// `bot_command` entity, and @chat-adapter/telegram diverts those
// updates away from the mention/subscribed-message handlers. This
// pins the delivery contract: an intercepted update must still reach
// the connector turn handler through the slash-command path.
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ ok: true, result: {} }), {
status: 200,
}),
),
);
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
const { Chat, ConsoleLogger } = await import("chat");
const { InMemoryStateAdapter } = await import("../stores/memory-state");
const telegram = createTelegramAdapter({
mode: "polling",
botToken: "123456:TEST-TOKEN",
userName: "test_bot",
logger: new ConsoleLogger("error", "telegram-slash-test"),
});
const bot = new Chat({
userName: "test_bot",
adapters: { telegram },
state: new InMemoryStateAdapter(),
logger: new ConsoleLogger("error", "telegram-slash-test"),
});
// bot.initialize() assigns this reference before polling starts; set
// it directly to avoid the real getMe/long-polling network calls.
(telegram as unknown as { chat: unknown }).chat = bot;
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const turns: Array<{ threadId: string; isDM: boolean; text: string }> = [];
bot.onSlashCommand(
__test__.createTelegramSlashCommandHandler({
bot,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: async (thread, text) => {
turns.push({ threadId: thread.id, isDM: thread.isDM, text });
},
}) as never,
);
const completions: Promise<unknown>[] = [];
(
telegram as unknown as {
processUpdate: (update: unknown, options?: unknown) => void;
}
).processUpdate(
{
update_id: 1,
message: {
message_id: 42,
date: Math.floor(Date.now() / 1000),
chat: { id: 555, type: "private" },
from: {
id: 999,
is_bot: false,
first_name: "Alice",
username: "alice",
},
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
},
{
waitUntil: (task: Promise<unknown>) =>
completions.push(task.catch(() => undefined)),
},
);
await Promise.all(completions);
await vi.waitFor(() => {
expect(turns).toHaveLength(1);
});
expect(turns[0]).toEqual({
threadId: "telegram:555",
isDM: true,
text: "/clear",
});
});
it("routes intercepted slash commands into the connector turn handler", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, isSubscribed, getState } = createSlashTestThread({
id: "telegram:12345",
channelId: "telegram:12345",
isDM: true,
});
const botThread = vi.fn(() => thread);
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: botThread } as never,
bindingsPath,
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
await handler({
channel: { id: "telegram:12345" },
command: "/clear",
text: "",
raw: {
message_id: 7,
chat: { id: 12345, type: "private" },
from: { id: 999, username: "alice", first_name: "Alice" },
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
});
expect(botThread).toHaveBeenCalledWith("telegram:12345");
expect(isSubscribed()).toBe(true);
expect(handleTurn).toHaveBeenCalledWith(thread, "/clear");
expect(getState().participantKey).toBe("telegram:id:999");
});
it("preserves group-chat bot addressing in the forwarded command text", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const { thread } = createSlashTestThread({
id: "telegram:-100200",
channelId: "telegram:-100200",
isDM: false,
});
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
// The chat adapter strips "@test_bot" into command targeting before
// invoking slash handlers; the raw message text keeps it so the
// connector host can enforce group addressing rules.
await handler({
channel: { id: "telegram:-100200" },
command: "/tools",
text: "on",
raw: {
message_id: 8,
chat: { id: -100200, type: "supergroup" },
from: { id: 999, username: "alice" },
text: "/tools@test_bot on",
entities: [{ type: "bot_command", offset: 0, length: 15 }],
},
});
expect(handleTurn).toHaveBeenCalledWith(thread, "/tools@test_bot on");
});
it("falls back to the parsed command when the raw payload has no text", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const { thread } = createSlashTestThread({
id: "telegram:12345",
channelId: "telegram:12345",
isDM: true,
});
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
await handler({
channel: { id: "telegram:12345" },
command: "/cwd",
text: "/tmp",
raw: undefined,
});
expect(handleTurn).toHaveBeenCalledWith(thread, "/cwd /tmp");
});
it("delivers intercepted slash commands to the chat command host", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createSlashTestThread({
id: "telegram:777",
channelId: "telegram:777",
isDM: true,
initialState: {
sessionId: "session-1",
participantKey: "telegram:id:999",
participantLabel: "alice",
welcomeSentAt: "2026-03-17T00:00:00.000Z",
},
});
const stopRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
const baseStartRequest = slashTestStartRequest();
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath,
baseStartRequest: baseStartRequest as never,
handleTurn: (async (
turnThread: typeof thread,
text: string,
): Promise<void> => {
await handleConnectorUserTurn({
thread: turnThread as never,
text,
client: { stopRuntimeSession, deleteSession } as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "test_bot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
});
}) as never,
});
await handler({
channel: { id: "telegram:777" },
command: "/clear",
text: "",
raw: {
message_id: 9,
chat: { id: 777, type: "private" },
from: { id: 999, username: "alice" },
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
});
expect(deleteSession).toHaveBeenCalledWith("session-1", true);
expect(posts).toContainEqual({ raw: "Started a fresh session." });
});
});
@@ -1,100 +0,0 @@
import { describe, expect, it } from "vitest";
import { discordConnector } from "./discord";
import { gchatConnector } from "./gchat";
import { linearConnector } from "./linear";
import { slackConnector } from "./slack";
import { telegramConnector } from "./telegram";
import { whatsappConnector } from "./whatsapp";
/**
* Every connector runs with tools enabled unless the operator opts out, so this
* has to hold for all of them at once rather than per adapter — the whole point
* is that there is no adapter where the default is different.
*/
const connectors: Array<{
name: string;
connector: unknown;
/** Minimal arguments that parse for this adapter. */
baseArgs: string[];
}> = [
{
name: "slack",
connector: slackConnector,
baseArgs: ["--user-name", "bot"],
},
{
name: "discord",
connector: discordConnector,
baseArgs: ["--application-id", "app-1", "--bot-token", "token"],
},
{
name: "linear",
connector: linearConnector,
baseArgs: [
"--user-name",
"bot",
"--api-key",
"key",
"--webhook-secret",
"secret",
],
},
{
name: "gchat",
connector: gchatConnector,
baseArgs: ["--user-name", "bot"],
},
{
name: "whatsapp",
connector: whatsappConnector,
baseArgs: ["--user-name", "bot", "--phone-number-id", "123"],
},
{
name: "telegram",
connector: telegramConnector,
baseArgs: ["--bot-token", "123:token"],
},
];
function parse(
connector: unknown,
rawArgs: string[],
): { enableTools: boolean } {
return (
connector as {
parseArgs(rawArgs: string[]): { enableTools: boolean };
}
).parseArgs(rawArgs);
}
describe("connector tools default", () => {
for (const { name, connector, baseArgs } of connectors) {
it(`${name}: enables tools when nothing is passed`, () => {
expect(parse(connector, baseArgs).enableTools).toBe(true);
});
it(`${name}: disables tools with --no-tools`, () => {
expect(parse(connector, [...baseArgs, "--no-tools"]).enableTools).toBe(
false,
);
});
it(`${name}: an explicit --no-tools beats --enable-tools`, () => {
// Ambiguous input resolves to the safer answer.
expect(
parse(connector, [...baseArgs, "--enable-tools", "--no-tools"])
.enableTools,
).toBe(false);
});
}
it("keeps accepting --enable-tools so existing invocations still parse", () => {
// Persisted autostart arguments and deployed scripts carry this flag; it is
// redundant now but must not become an unknown-option error.
for (const { connector, baseArgs } of connectors) {
expect(
parse(connector, [...baseArgs, "--enable-tools"]).enableTools,
).toBe(true);
}
});
});
-230
View File
@@ -1,230 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectorBase } from "./base";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
import type { ConnectIo } from "./types";
const mocks = vi.hoisted(() => ({
isProcessRunning: vi.fn(),
spawnDetachedConnector: vi.fn(),
terminateProcess: vi.fn(),
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal<typeof import("./common")>()),
isProcessRunning: mocks.isProcessRunning,
spawnDetachedConnector: mocks.spawnDetachedConnector,
terminateProcess: mocks.terminateProcess,
}));
class TestConnector extends ConnectorBase<
Record<string, never>,
{ pid: number }
> {
constructor() {
super("test", "Test connector");
}
protected readOptions(): Record<string, never> {
return {};
}
protected async runWithOptions(): Promise<number> {
return 0;
}
runBackground(
io: ConnectIo,
options?: {
readState?: () => { pid: number } | undefined;
isRunning?: (state: { pid: number }) => boolean;
startupTimeoutMs?: number;
},
): Promise<number | undefined> {
return this.maybeRunInBackground({
rawArgs: ["--token", "secret"],
io,
interactive: false,
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
statePath: "/tmp/test-connector.json",
readState: options?.readState ?? (() => undefined),
isRunning: options?.isRunning ?? (() => false),
formatAlreadyRunningMessage: () => "already running",
formatBackgroundStartMessage: (pid) => `started ${pid}`,
foregroundHint: "foreground hint",
launchFailureMessage: "launch failed",
startupTimeoutMs: options?.startupTimeoutMs,
});
}
stopProcess(
io: ConnectIo,
options: {
statePath: string;
readState: (path: string) => { pid: number } | undefined;
stopSessions?: (state: { pid: number }) => Promise<number>;
clearBindings?: (state: { pid: number }) => void;
},
) {
return this.stopManagedProcess({
io,
statePath: options.statePath,
readState: options.readState,
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
getPid: (state) => state.pid,
stopSessions: options.stopSessions ?? (async () => 0),
clearBindings: options.clearBindings,
});
}
}
describe("ConnectorBase background launch", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.isProcessRunning.mockReturnValue(true);
mocks.terminateProcess.mockResolvedValue(true);
});
it("returns a failure exit code when the detached process is not created", async () => {
mocks.spawnDetachedConnector.mockReturnValue(0);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
});
it("returns success only after a detached process receives a pid", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
let reads = 0;
await expect(
new TestConnector().runBackground(io, {
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
isRunning: () => true,
}),
).resolves.toBe(0);
expect(io.writeln).toHaveBeenCalledWith("started 42");
});
it("fails when the detached child exits before becoming ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining(
"launch failed: child exited before becoming ready",
),
);
});
it("points at the child log so a startup failure is diagnosable", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await new TestConnector().runBackground(io);
const [message] = vi.mocked(io.writeErr).mock.calls[0] ?? [];
expect(message).toContain("logs/connectors/test/test-connector.log");
expect(mocks.spawnDetachedConnector).toHaveBeenCalledWith(
["connect", "test"],
["--token", "secret"],
"CLINE_TEST_CONNECT_CHILD",
expect.objectContaining({
logPath: expect.stringContaining(
"logs/connectors/test/test-connector.log",
),
}),
);
});
it("terminates a detached child that never becomes ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
await expect(
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
).resolves.toBe(1);
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining("launch failed: timed out after 0ms"),
);
});
it("returns a distinct result when a connector is already running", async () => {
await expect(
new TestConnector().runBackground(io, {
readState: () => ({ pid: 99 }),
isRunning: () => true,
}),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(io.writeln).toHaveBeenCalledWith("already running");
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
});
it("keeps state and reports failure when the process survives termination", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(true);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
expect(removeStateFile).not.toHaveBeenCalled();
expect(stopSessions).not.toHaveBeenCalled();
expect(clearBindings).not.toHaveBeenCalled();
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] failed to stop connector process pid=42",
);
});
it("cleans stale state after confirming the process is already gone", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(false);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 1,
});
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
});
});
-459
View File
@@ -1,459 +0,0 @@
import {
closeSync,
existsSync,
openSync,
readdirSync,
readSync,
statSync,
} from "node:fs";
import { basename, join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { isSupervisedConnectorProcess } from "@cline/shared";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
resolveConnectorDebugLogPath,
spawnDetachedConnector,
terminateProcess,
tryClaimConnectorStateFile,
writeJsonFile,
} from "./common";
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "./types";
const SHOW_HELP_ERROR = "__SHOW_HELP__";
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
const CONNECTOR_STARTUP_POLL_MS = 100;
const CHILD_LOG_TAIL_BYTES = 8_192;
const CHILD_LOG_TAIL_LINES = 3;
const ESC = String.fromCharCode(27);
const BEL = String.fromCharCode(7);
const ANSI_SEQUENCE_PATTERN = new RegExp(
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
"g",
);
function stripAnsiCodes(text: string): string {
return text.replace(ANSI_SEQUENCE_PATTERN, "");
}
/**
* Surface why a detached connector child died. The child's own error is the
* useful part; the parent only knows that it exited, so quote the tail of the
* child's log and point at the file for the rest.
*/
function formatChildLogHint(logPath: string): string {
const suffix = ` See ${logPath} for details.`;
let handle: number | undefined;
try {
const { size } = statSync(logPath);
if (size === 0) {
return suffix;
}
const length = Math.min(size, CHILD_LOG_TAIL_BYTES);
const buffer = Buffer.alloc(length);
handle = openSync(logPath, "r");
const read = readSync(handle, buffer, 0, length, size - length);
const lines = buffer
.subarray(0, read)
.toString("utf8")
// A partial first line is likely when starting mid-file.
.split("\n")
.slice(size > length ? 1 : 0)
.map((line) => stripAnsiCodes(line).trim())
.filter((line) => line.length > 0)
.slice(-CHILD_LOG_TAIL_LINES);
if (lines.length === 0) {
return suffix;
}
return ` Last output from the child:\n${lines
.map((line) => ` ${line}`)
.join("\n")}\n${suffix.trimStart()}`;
} catch {
// The log is best-effort: a missing or unreadable file must never turn a
// startup failure into a crash.
return suffix;
} finally {
if (handle !== undefined) {
try {
closeSync(handle);
} catch {
// Nothing actionable if the descriptor is already gone.
}
}
}
}
export abstract class ConnectorBase<Options, State>
implements ConnectCommandDefinition
{
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
constructor(
public readonly name: string,
public readonly description: string,
) {}
protected createCommand(): Command {
return new Command(this.name)
.description(this.description)
.exitOverride()
.configureOutput({ writeOut: () => {}, writeErr: () => {} });
}
protected abstract readOptions(command: Command): Options;
protected abstract runWithOptions(
options: Options,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
protected async validateOptions(
_options: Options,
_io: ConnectIo,
): Promise<number> {
return 0;
}
showHelp(io: ConnectIo): void {
const output = this.createCommand().helpInformation().trimEnd();
for (const line of output.split("\n")) {
io.writeln(line);
}
}
async run(
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === SHOW_HELP_ERROR) {
this.showHelp(io);
return 0;
}
io.writeErr(message);
return 1;
}
const validationExitCode = await this.validateOptions(options, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
return this.runWithOptions(options, rawArgs, io, context);
}
/**
* The instance id `rawArgs` would run as, when that is knowable from the
* arguments alone.
*
* The hub keys supervision by (channel, instanceId), so it needs the id
* before anything is spawned. Adapters that can only determine it with a side
* effect — Telegram resolves its bot username from the API when the flag is
* omitted — return undefined, and the caller falls back to starting the
* connector locally.
*/
resolveInstanceId(rawArgs: string[]): string | undefined {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch {
return undefined;
}
const instanceId = this.instanceIdFromOptions(options);
return instanceId?.trim() ? instanceId.trim() : undefined;
}
protected instanceIdFromOptions(_options: Options): string | undefined {
return undefined;
}
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === SHOW_HELP_ERROR) {
this.showHelp(io);
return 0;
}
io.writeErr(message);
return 1;
}
return await this.validateOptions(options, io);
}
protected parseArgs(rawArgs: string[]): Options {
const command = this.createCommand();
try {
command.parse(rawArgs, { from: "user" });
} catch (error) {
if (
error instanceof CommanderError &&
error.code === "commander.helpDisplayed"
) {
throw new Error(SHOW_HELP_ERROR);
}
throw error;
}
return this.readOptions(command);
}
protected sanitizeKey(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
}
protected resolveConnectorPath(...segments: string[]): string {
return join(resolveClineDataDir(), "connectors", this.name, ...segments);
}
protected listJsonStatePaths(excludedSuffixes: string[] = []): string[] {
const dir = this.resolveConnectorPath();
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter(
(name) =>
name.endsWith(".json") &&
!excludedSuffixes.some((suffix) => name.endsWith(suffix)),
)
.map((name) => join(dir, name));
}
protected readStateFile<T>(
statePath: string,
isValid: (value: unknown) => value is T,
): T | undefined {
const parsed = readJsonFile<unknown>(statePath, undefined);
return isValid(parsed) ? parsed : undefined;
}
protected writeStateFile(statePath: string, state: unknown): void {
writeJsonFile(statePath, state);
}
protected removeStateFile(statePath: string): void {
removeFile(statePath);
}
protected removeStaleState(
statePath: string,
readState: (path: string) => State | undefined,
getPid: (state: State) => number,
): State | undefined {
const state = readState(statePath);
if (state && !isProcessRunning(getPid(state))) {
this.removeStateFile(statePath);
return state;
}
return undefined;
}
/**
* Exclusively claim the connector state path for this process before
* connecting to Slack/Discord/etc. Prevents two foreground (`-i`) or
* racing detached launches from both opening socket-mode with the same
* bot token.
*/
protected claimConnectorInstance(input: {
statePath: string;
createState: (claimId: string) => State & { claimId: string; pid: number };
readState: (path: string) => State | undefined;
getPid: (state: State) => number;
}): { claimed: true; claimId: string } | { claimed: false; running?: State } {
const existing = input.readState(input.statePath);
if (existing && isProcessRunning(input.getPid(existing))) {
return { claimed: false, running: existing };
}
const claim = tryClaimConnectorStateFile(
input.statePath,
input.createState,
);
if (!claim) {
const raced = input.readState(input.statePath);
return {
claimed: false,
...(raced ? { running: raced } : {}),
};
}
return { claimed: true, claimId: claim.claimId };
}
/**
* Where a detached child's stdout/stderr is captured. Without this the
* child is spawned with stdio "ignore", so a child that dies during startup
* takes its only diagnostic with it and the parent can report nothing but
* "child exited before becoming ready".
*/
protected resolveDetachedLogPath(statePath: string): string {
return resolveConnectorDebugLogPath(
this.name,
basename(statePath, ".json") || this.name,
);
}
protected async maybeRunInBackground(input: {
rawArgs: string[];
io: ConnectIo;
interactive: boolean;
childEnvVar: string;
statePath: string;
readState: (path: string) => State | undefined;
isRunning: (state: State) => boolean;
formatAlreadyRunningMessage: (state: State) => string;
formatBackgroundStartMessage: (pid: number) => string;
foregroundHint: string;
launchFailureMessage: string;
startupTimeoutMs?: number;
}): Promise<number | undefined> {
if (
input.interactive ||
process.env[input.childEnvVar] === "1" ||
// A supervised connector is the process the hub is tracking, so it must
// run the adapter here instead of handing off to a detached child.
isSupervisedConnectorProcess()
) {
return undefined;
}
const runningState = input.readState(input.statePath);
if (runningState && input.isRunning(runningState)) {
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
const logPath = this.resolveDetachedLogPath(input.statePath);
const pid = spawnDetachedConnector(
["connect", this.name],
input.rawArgs,
input.childEnvVar,
{
logPath,
component: `${this.name}-connect`,
metadata: { statePath: input.statePath },
},
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
return 1;
}
input.io.writeln(input.formatBackgroundStartMessage(pid));
input.io.writeln(input.foregroundHint);
const startedAt = Date.now();
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
while (Date.now() - startedAt < timeoutMs) {
const state = input.readState(input.statePath);
if (state && input.isRunning(state)) {
return 0;
}
if (!isProcessRunning(pid)) {
input.io.writeErr(
`${input.launchFailureMessage}: child exited before becoming ready.${formatChildLogHint(logPath)}`,
);
return 1;
}
await new Promise((resolve) =>
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
);
}
await terminateProcess(pid);
input.io.writeErr(
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms.${formatChildLogHint(logPath)}`,
);
return 1;
}
protected async stopAllFromStatePaths(
io: ConnectIo,
statePaths: string[],
stopInstance: (
statePath: string,
io: ConnectIo,
) => Promise<ConnectStopResult>,
): Promise<ConnectStopResult> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
for (const statePath of statePaths) {
const result = await stopInstance(statePath, io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions };
}
protected async stopManagedProcess(input: {
io: ConnectIo;
statePath: string;
readState: (path: string) => State | undefined;
describeStoppedProcess: (state: State) => string;
getPid: (state: State) => number;
stopSessions: (state: State) => Promise<number>;
clearBindings?: (state: State) => void;
}): Promise<ConnectStopResult> {
const state = input.readState(input.statePath);
if (!state) {
this.removeStateFile(input.statePath);
return {
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
};
}
const pid = input.getPid(state);
let stoppedProcesses = 0;
if (await terminateProcess(pid)) {
stoppedProcesses = 1;
input.io.writeln(input.describeStoppedProcess(state));
} else if (isProcessRunning(pid)) {
input.io.writeErr(
`[connect] failed to stop connector process pid=${pid}`,
);
return {
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
};
}
const stoppedSessions = await input.stopSessions(state);
input.clearBindings?.(state);
this.removeStateFile(input.statePath);
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
}
protected parseOptionalInteger(
value: string | undefined,
label: string,
): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`invalid ${label} "${value}"`);
}
return parsed;
}
protected parseMode(value: string | undefined): "act" | "plan" {
const mode = value?.trim().toLowerCase() || "act";
if (mode !== "act" && mode !== "plan") {
throw new Error(`invalid mode "${mode}" (expected "act" or "plan")`);
}
return mode;
}
}
-2
View File
@@ -1,2 +0,0 @@
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
-534
View File
@@ -1,534 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
__test__,
readSessionMessageCount,
readSessionReplyText,
} from "./common";
describe("spawnDetachedConnector", () => {
it("preserves the connect subcommand when building detached connector args", () => {
expect(
__test__.buildDetachedConnectorArgs(
["connect", "telegram"],
["-m", "ClineAdapterBot", "-k", "token-123"],
),
).toEqual([
"connect",
"telegram",
"-m",
"ClineAdapterBot",
"-k",
"token-123",
"-i",
]);
});
it("preserves bun conditions and resolves the cli entrypoint for detached launches", () => {
const connectorsDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(connectorsDir, "../../../../");
expect(
__test__.buildDetachedConnectorCommand(
["connect", "telegram"],
["-m", "ClineAdapterBot", "-k", "token-123"],
"/Users/test/.bun/bin/bun",
"./apps/cli/src/index.ts",
["--conditions=development"],
repoRoot,
{},
),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--inspect=127.0.0.1:0",
"--enable-source-maps",
"--conditions=development",
resolve(repoRoot, "apps/cli/src/index.ts"),
"connect",
"telegram",
"-m",
"ClineAdapterBot",
"-k",
"token-123",
"-i",
],
});
});
it("uses a dynamic connector inspector port for development node launches", () => {
const connectorsDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(connectorsDir, "../../../../");
expect(
__test__.buildDetachedConnectorCommand(
["connect", "telegram"],
["-m", "ClineAdapterBot"],
"/usr/local/bin/node",
"./apps/cli/src/index.ts",
[],
repoRoot,
{ CLINE_BUILD_ENV: "development" },
),
).toEqual({
launcher: "/usr/local/bin/node",
childArgs: [
"--inspect=127.0.0.1:0",
"--enable-source-maps",
resolve(repoRoot, "apps/cli/src/index.ts"),
"connect",
"telegram",
"-m",
"ClineAdapterBot",
"-i",
],
});
});
it("marks detached children and removes the hub-daemon-only environment flag", () => {
const env = {
CLINE_BUILD_ENV: "production",
CLINE_RUN_AS_HUB_DAEMON: "1",
UNCHANGED: "value",
};
expect(
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
).toEqual({
CLINE_BUILD_ENV: "production",
CLINE_CONNECTOR_DETACHED_CHILD: "1",
CLINE_TELEGRAM_CONNECT_CHILD: "1",
UNCHANGED: "value",
});
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
});
});
describe("readSessionReplyText", () => {
it("reads messages through the hub session client", async () => {
const client = {
readMessages: async () => [
{
role: "user",
content: [{ type: "text", text: "question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "first" }],
},
{
role: "assistant",
content: [
{ type: "text", text: "latest " },
{ type: "text", text: "reply" },
],
},
],
};
await expect(
readSessionReplyText(client as never, "session-1"),
).resolves.toBe("latest reply");
});
it("can restrict fallback replies to messages after a known boundary", async () => {
const client = {
readMessages: async () => [
{
role: "assistant",
content: [{ type: "text", text: "previous reply" }],
},
{
role: "user",
content: [{ type: "text", text: "next question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "current reply" }],
},
],
};
await expect(
readSessionReplyText(client as never, "session-1", {
minMessageIndex: 1,
}),
).resolves.toBe("current reply");
});
it("does not read prior assistant replies before the boundary", async () => {
const client = {
readMessages: async () => [
{
role: "assistant",
content: [{ type: "text", text: "previous reply" }],
},
{
role: "user",
content: [{ type: "text", text: "next question" }],
},
],
};
await expect(
readSessionReplyText(client as never, "session-1", {
minMessageIndex: 1,
}),
).resolves.toBeUndefined();
});
it("reads the session message count through the hub session client", async () => {
const client = {
readMessages: async () => [
{ role: "user", content: "one" },
{ role: "assistant", content: "two" },
],
};
await expect(
readSessionMessageCount(client as never, "session-1"),
).resolves.toBe(2);
});
});
describe("tryClaimConnectorStateFile", () => {
it("claims an empty path and rejects a second live claim", async () => {
const { mkdtempSync, readFileSync, rmSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const { tryClaimConnectorStateFile } = await import("./common");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
try {
const first = tryClaimConnectorStateFile(
statePath,
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
{
isRunning: () => true,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(first).toBeDefined();
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as {
claimId: string;
pid: number;
};
expect(parsed.claimId).toBe(first?.claimId);
expect(parsed.pid).toBe(process.pid);
const second = tryClaimConnectorStateFile(
statePath,
(claimId) => ({
claimId,
pid: process.pid + 1,
userName: "bot",
}),
{
isRunning: () => true,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(second).toBeUndefined();
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("replaces a dead-pid claim", async () => {
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const { tryClaimConnectorStateFile } = await import("./common");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
writeFileSync(
statePath,
JSON.stringify({ pid: 1, userName: "stale" }),
"utf8",
);
try {
const claimed = tryClaimConnectorStateFile(
statePath,
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
{
isRunning: (pid) => pid === process.pid,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(claimed).toBeDefined();
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("allows only one contender to replace the same stale generation", async () => {
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const firstPayload = `${JSON.stringify({
claimId: "first",
pid: 2,
userName: "bot",
})}
`;
const secondPayload = `${JSON.stringify({
claimId: "second",
pid: 3,
userName: "bot",
})}
`;
writeFileSync(statePath, stalePayload, "utf8");
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
firstPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(true);
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
secondPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(false);
expect(readFileSync(statePath, "utf8")).toBe(firstPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("recovers when a stale-generation guard owner exits before replacement", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
const orphanedGuardPath = `${statePath}.${generation}.claim`;
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
orphanedGuardPath,
`${JSON.stringify(
{
claimId: "orphaned",
pid: 3,
processStartToken: "process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(true);
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not succeed a live stale-generation guard owner", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
`${statePath}.${generation}.claim`,
`${JSON.stringify(
{
claimId: "live",
pid: 3,
processStartToken: "process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: (pid) => pid === 3,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(false);
expect(readFileSync(statePath, "utf8")).toBe(stalePayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("recovers when an orphaned guard pid belongs to a different process", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
`${statePath}.${generation}.claim`,
`${JSON.stringify(
{
claimId: "orphaned",
pid: 3,
processStartToken: "original-process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: (pid) => pid === 3,
getStartToken: (pid) =>
pid === 3 ? "reused-process-3" : `process-${pid}`,
},
),
).toBe(true);
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("detached connector log rotation", () => {
it("keeps one generation once the log grows past the cap", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
const logPath = join(dir, "cline-slack.log");
writeFileSync(logPath, "x".repeat(__test__.DETACHED_LOG_MAX_BYTES + 1));
__test__.rotateOversizedLog(logPath);
expect(existsSync(logPath)).toBe(false);
expect(readFileSync(`${logPath}.1`, "utf8").length).toBe(
__test__.DETACHED_LOG_MAX_BYTES + 1,
);
});
it("leaves a small log in place so restarts keep their history", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
const logPath = join(dir, "cline-slack.log");
writeFileSync(logPath, "recent failure");
__test__.rotateOversizedLog(logPath);
expect(readFileSync(logPath, "utf8")).toBe("recent failure");
expect(existsSync(`${logPath}.1`)).toBe(false);
});
it("does nothing when there is no log yet", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
expect(() =>
__test__.rotateOversizedLog(join(dir, "missing.log")),
).not.toThrow();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,195 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockGetLastUsedProviderSettings,
mockGetProviderSettings,
mockResolveSystemPrompt,
mockGetProviderCollection,
mockGetBooleanFlagEnabled,
} = vi.hoisted(() => ({
mockGetLastUsedProviderSettings: vi.fn(),
mockGetProviderSettings: vi.fn(),
mockResolveSystemPrompt: vi.fn(),
mockGetProviderCollection: vi.fn(),
mockGetBooleanFlagEnabled: vi.fn(),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return mockGetProviderSettings(providerId);
}
},
CoreSessionService: class {},
SqliteSessionStore: class {},
Llms: {
...actual.Llms,
getProviderCollection: mockGetProviderCollection,
},
};
});
vi.mock("../runtime/prompt", () => ({
resolveSystemPrompt: mockResolveSystemPrompt,
}));
vi.mock("../utils/helpers", () => ({
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
}));
vi.mock("../utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
}),
}));
vi.mock("../commands/auth", async () => {
const actual =
await vi.importActual<typeof import("../commands/auth")>(
"../commands/auth",
);
return {
...actual,
ensureOAuthProviderApiKey: vi.fn(),
};
});
import {
buildConnectorStartRequest,
isReusableConnectorSession,
} from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
mockGetBooleanFlagEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENROUTER_API_KEY;
});
it("falls back to provider env vars when persisted settings have no api key", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "openrouter" });
mockGetProviderSettings.mockReturnValue({
provider: "openrouter",
model: "anthropic/claude-sonnet-4.6",
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["OPENROUTER_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
process.env.OPENROUTER_API_KEY = "env-openrouter-key";
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
});
expect(request.provider).toBe("openrouter");
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: true,
});
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
describe("isReusableConnectorSession", () => {
it("rejects missing and terminal sessions", () => {
expect(isReusableConnectorSession(undefined)).toBe(false);
expect(isReusableConnectorSession({ sessionId: "" })).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "completed" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "failed" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "aborted" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "cancelled" }),
).toBe(false);
});
it("accepts live and status-omitted sessions", () => {
expect(
isReusableConnectorSession({ sessionId: "s1", status: "running" }),
).toBe(true);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "idle" }),
).toBe(true);
expect(isReusableConnectorSession({ sessionId: "s1" })).toBe(true);
});
});
@@ -1,312 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Thread } from "chat";
import { afterEach, describe, expect, it } from "vitest";
import {
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
isParticipantMuted,
isThreadMuted,
readBindingForThread,
readBindings,
setParticipantMuted,
setThreadMuted,
writeBindings,
} from "./thread-bindings";
type TestState = ConnectorThreadState & {
teamId?: string;
};
const tempDirs: string[] = [];
function createBindingsPath(): string {
const dir = mkdtempSync(join(tmpdir(), "thread-bindings-"));
tempDirs.push(dir);
return join(dir, "bindings.json");
}
function createThread(input: {
id: string;
channelId: string;
isDM: boolean;
participantKey?: string;
}): Thread<TestState> {
return {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
toJSON: () => ({
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
}),
} as unknown as Thread<TestState>;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("thread binding refresh", () => {
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
legacy_thread_id: {
channelId: "slack:C123",
isDM: true,
serializedThread: JSON.stringify({
id: "legacy_thread_id",
channelId: "slack:C123",
isDM: true,
}),
sessionId: "sess-1",
state: { sessionId: "sess-1", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const binding = readBindingForThread<TestState>(
path,
createThread({
id: "new_thread_id",
channelId: "slack:C123",
isDM: true,
}),
"Slack",
);
expect(binding?.serializedThread).toContain("new_thread_id");
const bindings = readBindings<TestState>(path);
expect(bindings.legacy_thread_id).toBeUndefined();
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
});
it("does not rebind a different thread by participant key", () => {
const path = createBindingsPath();
const participantKey = "slack:team:T123:user:U123";
writeBindings<TestState>(path, {
[participantKey]: {
channelId: "slack:C123",
isDM: false,
participantKey,
serializedThread: JSON.stringify({
id: "legacy_thread_id",
channelId: "slack:C123",
isDM: false,
}),
sessionId: "sess-1",
state: {
sessionId: "sess-1",
teamId: "T123",
participantKey,
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const binding = readBindingForThread<TestState>(
path,
createThread({
id: "new_thread_id",
channelId: "slack:C123",
isDM: false,
}),
"Slack",
participantKey,
);
expect(binding).toBeUndefined();
expect(
readBindings<TestState>(path)[participantKey]?.serializedThread,
).toContain("legacy_thread_id");
});
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
"slack:C123:111.222": {
kind: "conversation",
channelId: "slack:C123",
isDM: false,
participantKey: "slack:team:T123:user:U123",
serializedThread: "{}",
sessionId: "sess-thread",
state: {
sessionId: "sess-thread",
participantKey: "slack:team:T123:user:U123",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const match = findBindingForDeliveryTarget<TestState>(
readBindings<TestState>(path),
{
bindingKey: "slack:C123:111.222",
threadId: "slack:C123:111.222",
participantKey: "slack:team:T123:user:U123",
},
);
expect(match?.key).toBe("slack:C123:111.222");
expect(match?.binding.sessionId).toBe("sess-thread");
});
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
"slack:team:T123:user:U123": {
channelId: "slack:C123",
isDM: true,
participantKey: "slack:team:T123:user:U123",
serializedThread: "{}",
sessionId: "sess-participant",
state: {
sessionId: "sess-participant",
participantKey: "slack:team:T123:user:U123",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const match = findBindingForDeliveryTarget<TestState>(
readBindings<TestState>(path),
{
participantKey: "slack:team:T123:user:U123",
},
);
expect(match?.key).toBe("slack:team:T123:user:U123");
expect(match?.binding.sessionId).toBe("sess-participant");
});
it("stores mute state at thread scope instead of participant scope", () => {
const path = createBindingsPath();
const thread = createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
participantKey: "discord:user:alice",
});
setThreadMuted(path, thread, true, "Discord");
expect(
isThreadMuted(
path,
createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
participantKey: "discord:user:bob",
}),
),
).toBe(true);
const binding = readBindingForThread<TestState>(
path,
thread,
"Discord",
"discord:user:alice",
);
expect(binding).toBeUndefined();
setThreadMuted(path, thread, false, "Discord");
expect(isThreadMuted(path, thread)).toBe(false);
});
it("stores participant mute state scoped to the current thread", () => {
const path = createBindingsPath();
const thread = createThread({
id: "thread-1",
channelId: "discord:guild:channel",
isDM: false,
});
const otherThread = createThread({
id: "thread-2",
channelId: "discord:guild:channel",
isDM: false,
});
setParticipantMuted(
path,
thread,
{
participantKey: "discord:user:bob",
participantLabel: "Bob",
},
true,
"Discord",
);
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(true);
expect(isParticipantMuted(path, thread, "discord:user:alice")).toBe(false);
expect(isParticipantMuted(path, otherThread, "discord:user:bob")).toBe(
false,
);
expect(
readBindingForThread<TestState>(
path,
thread,
"Discord",
"discord:user:bob",
),
).toBeUndefined();
setParticipantMuted(
path,
thread,
{ participantKey: "discord:user:bob" },
false,
"Discord",
);
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(false);
});
});
describe("clearBindingSessionIds", () => {
it("clears session ids from bindings and serialized thread state", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
thread_1: {
channelId: "discord:C123",
isDM: false,
serializedThread: JSON.stringify({
id: "thread_1",
channelId: "discord:C123",
isDM: false,
sessionId: "legacy-root-session",
state: {
sessionId: "sess-1",
cwd: "/tmp/work",
teamId: "T123",
},
}),
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
clearBindingSessionIds<TestState>(path);
const binding = readBindings<TestState>(path).thread_1;
expect(binding?.sessionId).toBeUndefined();
expect(binding?.state?.sessionId).toBeUndefined();
expect(binding?.state?.cwd).toBe("/tmp/work");
const serializedThread = JSON.parse(binding?.serializedThread ?? "{}") as {
sessionId?: string;
state?: TestState;
};
expect(serializedThread.sessionId).toBeUndefined();
expect(serializedThread.state?.sessionId).toBeUndefined();
expect(serializedThread.state?.cwd).toBe("/tmp/work");
});
});
@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import { enqueueThreadTurn } from "./chat-runtime";
import { resolveThreadTurnQueueKey } from "./thread-bindings";
describe("resolveThreadTurnQueueKey", () => {
it("gives every channel thread its own key", () => {
// Channel threads each own a binding and a session, so they run in parallel.
const first = resolveThreadTurnQueueKey({
id: "slack:C1:1111.1",
channelId: "slack:C1",
isDM: false,
});
const second = resolveThreadTurnQueueKey({
id: "slack:C1:2222.2",
channelId: "slack:C1",
isDM: false,
});
expect(first).not.toBe(second);
expect(first).toBe("slack:C1:1111.1");
});
it("collapses every message in one DM onto a single key", () => {
// findBindingForThread reuses one binding for a whole DM channel, so those
// messages share a session and must not run concurrently.
const first = resolveThreadTurnQueueKey({
id: "slack:D1:1111.1",
channelId: "slack:D1",
isDM: true,
});
const second = resolveThreadTurnQueueKey({
id: "slack:D1:2222.2",
channelId: "slack:D1",
isDM: true,
});
expect(first).toBe(second);
});
it("keeps separate DM channels separate", () => {
expect(
resolveThreadTurnQueueKey({
id: "slack:D1:1111.1",
channelId: "slack:D1",
isDM: true,
}),
).not.toBe(
resolveThreadTurnQueueKey({
id: "slack:D2:1111.1",
channelId: "slack:D2",
isDM: true,
}),
);
});
});
describe("thread turn scheduling", () => {
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
it("runs two messages in the same DM one after the other", async () => {
const queues = new Map<string, Promise<void>>();
const dm = { id: "slack:D1:1.1", channelId: "slack:D1", isDM: true };
const later = { id: "slack:D1:2.2", channelId: "slack:D1", isDM: true };
const order: string[] = [];
const first = deferred();
const firstTurn = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(dm),
async () => {
order.push("first:start");
await first.promise;
order.push("first:end");
},
);
const secondTurn = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(later),
async () => {
order.push("second:start");
},
);
// The second message must not touch the shared session until the first
// message's run has finished.
await new Promise((resolve) => setImmediate(resolve));
expect(order).toEqual(["first:start"]);
first.resolve();
await Promise.all([firstTurn, secondTurn]);
expect(order).toEqual(["first:start", "first:end", "second:start"]);
});
it("runs two channel threads at the same time", async () => {
const queues = new Map<string, Promise<void>>();
const threadA = { id: "slack:C1:1.1", channelId: "slack:C1", isDM: false };
const threadB = { id: "slack:C1:2.2", channelId: "slack:C1", isDM: false };
const order: string[] = [];
const blocked = deferred();
const turnA = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(threadA),
async () => {
order.push("a:start");
await blocked.promise;
order.push("a:end");
},
);
const turnB = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(threadB),
async () => {
order.push("b:start");
},
);
// B answers while A is still working: separate threads, separate sessions.
await new Promise((resolve) => setImmediate(resolve));
expect(order).toEqual(["a:start", "b:start"]);
blocked.resolve();
await Promise.all([turnA, turnB]);
expect(order).toEqual(["a:start", "b:start", "a:end"]);
});
});

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