mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f9e73d8d |
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -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: 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
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
../../.cline/skills/publish-desktop
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/publish-extension
|
||||
@@ -1 +0,0 @@
|
||||
../../.cline/skills/tuistory
|
||||
@@ -1,157 +0,0 @@
|
||||
---
|
||||
name: publish-desktop
|
||||
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
|
||||
---
|
||||
|
||||
# Desktop App Release
|
||||
|
||||
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` 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.
|
||||
|
||||
## Release contract
|
||||
|
||||
- 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.)
|
||||
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
|
||||
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
|
||||
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
|
||||
- The workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
|
||||
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
|
||||
- Always ask before pushing commits or tags.
|
||||
|
||||
## Workflow
|
||||
|
||||
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.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-desktop-tag>..HEAD --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.
|
||||
|
||||
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
- `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) 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"
|
||||
git push origin refs/tags/desktop-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
The release commit must be on `main` and the tag pushed first.
|
||||
|
||||
```sh
|
||||
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -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`), verifies every Mach-O in the bundle carries both slices, 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, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 2–10 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
|
||||
```
|
||||
|
||||
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — pick the update up on next launch or within 2 hours.
|
||||
|
||||
10. Final response.
|
||||
|
||||
Report: 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.
|
||||
@@ -1,182 +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 legacy-ref=legacy-extension -f publish=true
|
||||
# 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 -f branch=legacy-extension
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)`
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -375,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 }}
|
||||
@@ -424,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'
|
||||
|
||||
@@ -1,455 +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
|
||||
|
||||
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 }}
|
||||
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 }}
|
||||
run: |
|
||||
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 +main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
|
||||
echo "${TAG} is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$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.
|
||||
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
|
||||
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
|
||||
env:
|
||||
# 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
|
||||
run: |
|
||||
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.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: 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 }}
|
||||
run: |
|
||||
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
|
||||
OUT="dist/publish"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
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/Cline-Code_${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/Cline-Code_${VERSION}_universal.app.tar.gz"
|
||||
cp "${TARBALL}.sig" "$OUT/Cline-Code_${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
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- 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 }}
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
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"
|
||||
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 (desktop-latest)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! gh release view desktop-latest >/dev/null 2>&1; then
|
||||
gh release create desktop-latest \
|
||||
--title "Cline Code 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
|
||||
gh release upload desktop-latest dist/desktop/latest.json --clobber
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
run: |
|
||||
echo "Published Cline Code 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/desktop-latest/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 Code desktop v${{ needs.validate.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — 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) || '' }}"
|
||||
@@ -1,551 +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
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
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-ref 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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
|
||||
- name: Record tested revision
|
||||
id: rev
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
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"
|
||||
|
||||
- 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.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,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,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}"
|
||||
|
||||
@@ -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,60 +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
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -257,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:
|
||||
|
||||
@@ -45,16 +45,12 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
@@ -88,20 +84,26 @@ jobs:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: bun-cache
|
||||
id: root-cache
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -122,41 +124,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'
|
||||
@@ -165,11 +146,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -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}`)
|
||||
@@ -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}`)
|
||||
@@ -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,41 +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
|
||||
|
||||
- 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:
|
||||
@@ -315,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.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) || '' }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,149 +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
|
||||
|
||||
- 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
|
||||
|
||||
# The desktop chat test imports @cline/shared/browser, which resolves to
|
||||
# dist output that nothing else in this job builds.
|
||||
- name: Build shared package
|
||||
run: bun -F @cline/shared 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
@@ -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
@@ -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
|
||||
Vendored
+7
-6
@@ -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"
|
||||
|
||||
Vendored
+1
-14
@@ -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
|
||||
|
||||
Vendored
+30
-51
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
-382
@@ -1,387 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
+15
-15
@@ -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:**
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 "$@"
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -1,261 +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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
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: Message[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
await conn.sessionUpdate({ sessionId, update });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): 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 "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;
|
||||
default:
|
||||
return "[image]";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,694 +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(),
|
||||
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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
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 { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: mockSpawn,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
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 createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
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("post-update hub launch", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,673 +0,0 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { HubSessionClient, HubSessionRow } from "@cline/core";
|
||||
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
|
||||
"CLINE_CONNECTOR_DETACHED_CHILD";
|
||||
|
||||
/**
|
||||
* Internal success from a detached connect when an instance is already running.
|
||||
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
|
||||
* state.
|
||||
*/
|
||||
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
|
||||
|
||||
/** Rotate a detached connector log once it passes this size. */
|
||||
const DETACHED_LOG_MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
|
||||
export function parseStringFlag(
|
||||
rawArgs: string[],
|
||||
shortFlag: string,
|
||||
longFlag: string,
|
||||
): string | undefined {
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const value = rawArgs[index];
|
||||
if (value !== shortFlag && value !== longFlag) {
|
||||
continue;
|
||||
}
|
||||
const next = rawArgs[index + 1]?.trim();
|
||||
return next ? next : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseIntegerFlag(
|
||||
rawArgs: string[],
|
||||
shortFlag: string,
|
||||
longFlag: string,
|
||||
): number | undefined {
|
||||
const raw = parseStringFlag(rawArgs, shortFlag, longFlag);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessProbe = {
|
||||
isRunning: (pid: number) => boolean;
|
||||
getStartToken: (pid: number) => string | undefined;
|
||||
};
|
||||
|
||||
function getProcessStartToken(pid: number): string | undefined {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Fields after the command name begin at field 3 (state), so field
|
||||
// 22 (starttime) is index 19.
|
||||
const startTime = stat
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19];
|
||||
const bootId = readFileSync(
|
||||
"/proc/sys/kernel/random/boot_id",
|
||||
"utf8",
|
||||
).trim();
|
||||
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
|
||||
}
|
||||
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? spawnSync(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
const startTime = result.status === 0 ? result.stdout.trim() : "";
|
||||
return startTime ? `${process.platform}:${startTime}` : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProcessProbe: ProcessProbe = {
|
||||
isRunning: isProcessRunning,
|
||||
getStartToken: getProcessStartToken,
|
||||
};
|
||||
|
||||
export async function terminateProcess(pid: number): Promise<boolean> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return !isProcessRunning(pid);
|
||||
}
|
||||
|
||||
function buildDetachedConnectorArgs(
|
||||
commandPrefixArgs: string[],
|
||||
rawArgs: string[],
|
||||
): string[] {
|
||||
return [...commandPrefixArgs, ...rawArgs, "-i"];
|
||||
}
|
||||
|
||||
function buildDetachedConnectorCommand(
|
||||
commandPrefixArgs: string[],
|
||||
rawArgs: string[],
|
||||
execPath = process.execPath,
|
||||
entryArg = process.argv[1],
|
||||
execArgv = process.execArgv,
|
||||
cwd = process.cwd(),
|
||||
env = process.env,
|
||||
): { launcher: string; childArgs: string[] } | undefined {
|
||||
const spec = resolveCliLaunchSpec({
|
||||
execPath,
|
||||
argv: [process.argv[0] || "node", entryArg ?? ""],
|
||||
execArgv,
|
||||
cwd,
|
||||
env,
|
||||
debugRole: "connector",
|
||||
});
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
const commandArgs = buildDetachedConnectorArgs(commandPrefixArgs, rawArgs);
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...commandArgs],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetachedConnectorEnv(
|
||||
childEnvKey: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const childEnv = {
|
||||
...withResolvedClineBuildEnv(env),
|
||||
[childEnvKey]: "1",
|
||||
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
|
||||
};
|
||||
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
return childEnv;
|
||||
}
|
||||
|
||||
export function resolveConnectorDebugLogPath(
|
||||
adapterName: string,
|
||||
instanceKey: string,
|
||||
): string {
|
||||
const safeAdapter = adapterName.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
const safeKey = instanceKey.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
return join(
|
||||
resolveClineDataDir(),
|
||||
"logs",
|
||||
"connectors",
|
||||
safeAdapter,
|
||||
`${safeKey}.log`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectors are long-lived and restart often, so an append-only log would grow
|
||||
* without bound on a host that runs them for weeks. Keep one previous
|
||||
* generation and start fresh once the current one gets large.
|
||||
*/
|
||||
function rotateOversizedLog(path: string): void {
|
||||
try {
|
||||
if (statSync(path).size < DETACHED_LOG_MAX_BYTES) {
|
||||
return;
|
||||
}
|
||||
rmSync(`${path}.1`, { force: true });
|
||||
renameSync(path, `${path}.1`);
|
||||
} catch {
|
||||
// No log yet, or it cannot be rotated: appending is still fine.
|
||||
}
|
||||
}
|
||||
|
||||
function tryOpenDetachedLogFd(path: string | undefined): number | undefined {
|
||||
if (!path?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
ensureParentDir(path);
|
||||
rotateOversizedLog(path);
|
||||
return openSync(path, "a");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnDetachedConnector(
|
||||
commandPrefixArgs: string[],
|
||||
rawArgs: string[],
|
||||
childEnvKey: string,
|
||||
options?: {
|
||||
logPath?: string;
|
||||
component?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): number {
|
||||
const command = buildDetachedConnectorCommand(commandPrefixArgs, rawArgs);
|
||||
if (!command) {
|
||||
try {
|
||||
const logger = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: options?.component ?? "connectors",
|
||||
});
|
||||
logger.core.error?.("Unable to resolve detached connector command", {
|
||||
commandPrefixArgs,
|
||||
rawArgs,
|
||||
childEnvKey,
|
||||
entryArg: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
logPath: options?.logPath,
|
||||
...options?.metadata,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort logging only.
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const detachedLogFd = tryOpenDetachedLogFd(options?.logPath);
|
||||
try {
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio:
|
||||
detachedLogFd === undefined
|
||||
? "ignore"
|
||||
: ["ignore", detachedLogFd, detachedLogFd],
|
||||
env: buildDetachedConnectorEnv(childEnvKey),
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: options?.component ?? "connectors",
|
||||
command: [command.launcher, ...command.childArgs],
|
||||
childPid: child.pid ?? undefined,
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
metadata: {
|
||||
childEnvKey,
|
||||
purpose: "connector.detached",
|
||||
logPath: options?.logPath,
|
||||
...options?.metadata,
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
return child.pid ?? 0;
|
||||
} catch (error) {
|
||||
try {
|
||||
const logger = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
component: options?.component ?? "connectors",
|
||||
});
|
||||
logger.core.error?.("Failed to spawn detached connector", {
|
||||
error,
|
||||
command: [command.launcher, ...command.childArgs].join(" "),
|
||||
commandArgs: command.childArgs,
|
||||
childEnvKey,
|
||||
logPath: options?.logPath,
|
||||
...options?.metadata,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort logging only.
|
||||
}
|
||||
return 0;
|
||||
} finally {
|
||||
if (detachedLogFd !== undefined) {
|
||||
try {
|
||||
closeSync(detachedLogFd);
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildDetachedConnectorArgs,
|
||||
buildDetachedConnectorCommand,
|
||||
buildDetachedConnectorEnv,
|
||||
tryReplaceStaleConnectorStateFile,
|
||||
rotateOversizedLog,
|
||||
DETACHED_LOG_MAX_BYTES,
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
if (!existsSync(path)) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = JSON.parse(raw) as T;
|
||||
return parsed ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureParentDir(path);
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a connector state path for this process.
|
||||
*
|
||||
* Uses O_EXCL so two concurrent `cline connect` launches cannot both observe
|
||||
* "no running instance" and both proceed. Returns undefined when another live
|
||||
* connector already owns the path (or a concurrent claim won the race).
|
||||
*/
|
||||
export function tryClaimConnectorStateFile(
|
||||
statePath: string,
|
||||
createState: (
|
||||
claimId: string,
|
||||
) => { claimId: string; pid: number } & Record<string, unknown>,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): { claimId: string } | undefined {
|
||||
ensureParentDir(statePath);
|
||||
const claimId = randomUUID();
|
||||
const state = createState(claimId);
|
||||
const payload = `${JSON.stringify(state, null, 2)}
|
||||
`;
|
||||
|
||||
if (tryCreateConnectorStateFile(statePath, payload)) {
|
||||
return { claimId };
|
||||
}
|
||||
|
||||
let observedPayload: string;
|
||||
try {
|
||||
observedPayload = readFileSync(statePath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const existing = JSON.parse(observedPayload) as { pid?: unknown };
|
||||
const existingPid =
|
||||
typeof existing.pid === "number" ? existing.pid : undefined;
|
||||
if (existingPid !== undefined && processProbe.isRunning(existingPid)) {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return tryReplaceStaleConnectorStateFile(
|
||||
statePath,
|
||||
observedPayload,
|
||||
payload,
|
||||
processProbe,
|
||||
)
|
||||
? { claimId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function tryCreateConnectorStateFile(
|
||||
statePath: string,
|
||||
payload: string,
|
||||
): boolean {
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(statePath, "wx");
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
writeFileSync(fd, payload, "utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces exactly the stale generation that the caller observed.
|
||||
*
|
||||
* Each contender atomically links its ownership record into a guard keyed by
|
||||
* the observed generation. A live guard owner blocks replacement. If an owner
|
||||
* dies in the critical section, contenders append a successor guard rather
|
||||
* than deleting the existing one, so stale recovery remains crash-safe.
|
||||
*/
|
||||
function tryReplaceStaleConnectorStateFile(
|
||||
statePath: string,
|
||||
observedPayload: string,
|
||||
replacementPayload: string,
|
||||
processProbe: ProcessProbe = defaultProcessProbe,
|
||||
): boolean {
|
||||
let replacement: { claimId?: unknown; pid?: unknown };
|
||||
try {
|
||||
replacement = JSON.parse(replacementPayload) as {
|
||||
claimId?: unknown;
|
||||
pid?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof replacement.claimId !== "string" ||
|
||||
typeof replacement.pid !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const generation = createHash("sha256").update(observedPayload).digest("hex");
|
||||
const ownerPayload = `${JSON.stringify(
|
||||
{
|
||||
claimId: replacement.claimId,
|
||||
pid: replacement.pid,
|
||||
processStartToken: processProbe.getStartToken(replacement.pid),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
`;
|
||||
const candidatePath = `${statePath}.${replacement.claimId}.candidate`;
|
||||
if (!tryCreateConnectorStateFile(candidatePath, ownerPayload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const guardPaths: string[] = [];
|
||||
let acquiredGuard = false;
|
||||
try {
|
||||
let guardPath = `${statePath}.${generation}.claim`;
|
||||
while (true) {
|
||||
guardPaths.push(guardPath);
|
||||
try {
|
||||
linkSync(candidatePath, guardPath);
|
||||
acquiredGuard = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code)
|
||||
: undefined;
|
||||
if (code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let guardPayload: string;
|
||||
try {
|
||||
guardPayload = readFileSync(guardPath, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const guardOwner = JSON.parse(guardPayload) as {
|
||||
pid?: unknown;
|
||||
processStartToken?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof guardOwner.pid === "number" &&
|
||||
processProbe.isRunning(guardOwner.pid)
|
||||
) {
|
||||
const runningStartToken = processProbe.getStartToken(guardOwner.pid);
|
||||
if (
|
||||
typeof guardOwner.processStartToken !== "string" ||
|
||||
runningStartToken === undefined ||
|
||||
runningStartToken === guardOwner.processStartToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid ownership metadata cannot identify a live owner.
|
||||
}
|
||||
|
||||
const successor = createHash("sha256")
|
||||
.update(guardPath)
|
||||
.update("\0")
|
||||
.update(guardPayload)
|
||||
.digest("hex");
|
||||
guardPath = `${statePath}.${generation}.${successor}.claim`;
|
||||
}
|
||||
|
||||
if (readFileSync(statePath, "utf8") !== observedPayload) {
|
||||
return false;
|
||||
}
|
||||
rmSync(statePath);
|
||||
return tryCreateConnectorStateFile(statePath, replacementPayload);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
rmSync(candidatePath, { force: true });
|
||||
if (acquiredGuard) {
|
||||
for (const guardPath of guardPaths) {
|
||||
rmSync(guardPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function parseRowMetadata(
|
||||
row:
|
||||
| HubSessionRow
|
||||
| {
|
||||
metadata?: Record<string, unknown>;
|
||||
parentSessionId?: string | null;
|
||||
sessionId: string;
|
||||
},
|
||||
): { metadata?: Record<string, unknown>; parentSessionId?: string } {
|
||||
const metadata =
|
||||
row.metadata && typeof row.metadata === "object" ? row.metadata : undefined;
|
||||
return {
|
||||
metadata,
|
||||
parentSessionId: row.parentSessionId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLocalRowMetadata(row: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): Record<string, unknown> | undefined {
|
||||
return row.metadata && typeof row.metadata === "object"
|
||||
? row.metadata
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function extractMessageReplyText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = message as { role?: unknown; content?: unknown };
|
||||
if (record.role !== "assistant") {
|
||||
return undefined;
|
||||
}
|
||||
const content = record.content;
|
||||
if (typeof content === "string" && content.trim()) {
|
||||
return content.trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const joined = content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (!part || typeof part !== "object") {
|
||||
return "";
|
||||
}
|
||||
const record = part as { text?: unknown };
|
||||
return typeof record.text === "string" ? record.text : "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
|
||||
export async function readSessionReplyText(
|
||||
client: HubSessionClient,
|
||||
sessionId: string,
|
||||
options: { minMessageIndex?: number } = {},
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const messages = await client.readMessages(sessionId);
|
||||
const minMessageIndex = Math.max(0, options.minMessageIndex ?? 0);
|
||||
for (
|
||||
let index = messages.length - 1;
|
||||
index >= minMessageIndex;
|
||||
index -= 1
|
||||
) {
|
||||
const text = extractMessageReplyText(messages[index]);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function readSessionMessageCount(
|
||||
client: HubSessionClient,
|
||||
sessionId: string,
|
||||
): Promise<number | undefined> {
|
||||
try {
|
||||
return (await client.readMessages(sessionId)).length;
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,196 +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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
export type ConnectIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
};
|
||||
|
||||
export type ConnectStopResult = {
|
||||
stoppedProcesses: number;
|
||||
failedProcesses: number;
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectRunContext = {
|
||||
setPersistenceArgs: (args: string[]) => void;
|
||||
setPersistenceInstanceId: (instanceId: string) => void;
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
run(
|
||||
args: string[],
|
||||
io: ConnectIo,
|
||||
context: ConnectRunContext,
|
||||
): Promise<number>;
|
||||
validate(args: string[], io: ConnectIo): Promise<number>;
|
||||
showHelp(io: ConnectIo): void;
|
||||
/**
|
||||
* Instance id `args` would run as, when it is knowable without side effects.
|
||||
* The hub keys connector supervision by (channel, instanceId) and needs it
|
||||
* before spawning; undefined sends the caller to the local start path.
|
||||
*/
|
||||
resolveInstanceId?(args: string[]): string | undefined;
|
||||
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
|
||||
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMigrationNoticeKeyAction } from "./notice-dialog";
|
||||
|
||||
vi.mock("@opentui-ui/dialog/react", () => ({
|
||||
useDialogKeyboard: () => undefined,
|
||||
}));
|
||||
|
||||
describe("resolveMigrationNoticeKeyAction", () => {
|
||||
it("opens the subscription page on Enter", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "return" })).toBe("open");
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "enter" })).toBe("open");
|
||||
});
|
||||
|
||||
it("dismisses on Escape", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "escape" })).toBe("dismiss");
|
||||
});
|
||||
|
||||
it("dismisses on any other unmodified key so users are never stuck behind the promo", () => {
|
||||
for (const name of ["q", "x", "space", "tab", "backspace", "up"]) {
|
||||
expect(resolveMigrationNoticeKeyAction({ name })).toBe("dismiss");
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores modifier-held keys so holding Cmd/Ctrl to click the link never dismisses", () => {
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "c", ctrl: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", meta: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "x", super: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
// A bare modifier press (empty name) is ignored, not a dismiss.
|
||||
expect(resolveMigrationNoticeKeyAction({ name: "" })).toBe("ignore");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user