Compare commits

..

19 Commits

Author SHA1 Message Date
Robin Newhouse 8333e1e86b Align basic compaction budget expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse 25fa84c9e6 Align basic sanitization image expectation 2026-07-07 13:23:54 -07:00
Robin Newhouse da35f08f03 Clarify basic projection budget logging 2026-07-07 13:23:54 -07:00
Robin Newhouse 02bc1bc432 Budget basic compaction projections 2026-07-07 13:23:54 -07:00
Robin Newhouse 49c0d1b6a3 Align agentic file ops with projected input 2026-07-07 13:23:54 -07:00
Robin Newhouse 92dc5dfed3 Align agentic compaction test tool result 2026-07-07 13:23:53 -07:00
Robin Newhouse ce85e49c7b Harden agentic summary budget fallback 2026-07-07 13:23:53 -07:00
Robin Newhouse dfecadbcbd Budget agentic compaction summary input 2026-07-07 13:23:53 -07:00
Robin Newhouse 519a22c5d5 Fix budget projection action accounting 2026-07-07 13:23:53 -07:00
Robin Newhouse fbdfa77bb9 fix(core): narrow compaction protected tail 2026-07-07 13:23:53 -07:00
Robin Newhouse 5ad8d33977 Clean up budget projection fixture indentation 2026-07-07 13:23:53 -07:00
Robin Newhouse 5226b107ba Align budget projection test tool results 2026-07-07 13:23:53 -07:00
Robin Newhouse 26f015fbd1 Recompute protected tail after thinking pruning 2026-07-07 13:23:52 -07:00
Robin Newhouse bdce31deea Drop provider-native blocks during budget projection 2026-07-07 13:23:52 -07:00
Robin Newhouse 406674d27f Fix budget projection truncation accounting 2026-07-07 13:23:52 -07:00
Robin Newhouse 24303ab0cb Add pure compaction budget projection engine 2026-07-07 13:23:52 -07:00
Robin Newhouse f48ba92357 Tighten dropped block action paths 2026-07-07 13:23:52 -07:00
Robin Newhouse 3693d2f867 Tighten budget projection contract types 2026-07-07 13:23:52 -07:00
Robin Newhouse 86aca36d03 Add compaction budget projection contract 2026-07-07 13:23:52 -07:00
553 changed files with 11253 additions and 43731 deletions
+2 -3
View File
@@ -8,9 +8,8 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
-1
View File
@@ -16,7 +16,6 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
-205
View File
@@ -1,205 +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.
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 (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:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
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
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @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
# 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
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+47 -211
View File
@@ -1,40 +1,17 @@
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 +20,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,79 +30,60 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout next (SDK) source
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: 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.
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: next-src
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
@@ -135,24 +93,20 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Build next bundle
working-directory: next-src/apps/vscode
- name: Publish Nightly Extension
env:
CLINE_ENVIRONMENT: production
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -160,129 +114,12 @@ jobs:
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
working-directory: ${{ github.workspace }}
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -290,11 +127,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}"
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.101.0
VSCODE_TEST_VERSION: 1.103.0
strategy:
fail-fast: false
matrix:
-58
View File
@@ -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) || '' }}"
-7
View File
@@ -85,10 +85,3 @@ apps/vscode/webview-ui/src/**/*.js.map
.cline/**/managed.json
.cline/**/bundle.json
apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
+8
View File
@@ -39,6 +39,14 @@
"sdk/packages/core/src/auth/**"
],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": [
"sdk/packages/core/src/services/telemetry/core-events.ts"
],
"severity": "medium"
}
]
}
+5 -1
View File
@@ -16,9 +16,13 @@
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
"path": "DOC.md",
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "sdk/ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
},
{
"path": "sdk/AGENTS.md",
+17 -22
View File
@@ -36,13 +36,8 @@ event names. It exports:
1. Add the constant to `CORE_TELEMETRY_EVENTS`
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
**All events should be named using snake_case and so should their properties**
3. Update the Event Catalog section in `DOC.md`
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
## The Activation Funnel
@@ -87,7 +82,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
config dir.
The canonical pattern is in `apps/cli/src/main.ts`:
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
```ts
if (configDir) setClineDir(configDir);
@@ -95,18 +90,18 @@ setHomeDir(homedir());
captureCliExtensionActivated(); // <-- after dir overrides
```
## Hub Daemon Telemetry
## Hub Daemon Metadata Forwarding
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
metadata into the daemon argv so the daemon can reconstruct an equivalent
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
```
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
```
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
## Auth Lifecycle Completeness
@@ -125,10 +120,10 @@ canonical examples of all four phases.
## Single Telemetry Service Per Host
On VS Code, all callers go through the lazy `telemetryService` proxy in
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
use. Do not let individual controllers construct their own `ITelemetryService` — that
fragments distinct-id state, opt-out tracking, and flush ownership.
On VS Code, the telemetry handle is built **once** in `activate()`
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
command, and daemon spawn payload. Do not let individual controllers construct their own
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
+7 -7
View File
@@ -68,7 +68,7 @@
"command": "bun run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -89,7 +89,7 @@
"command": "bun run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": false,
"isBackground": true,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -114,16 +114,16 @@
{
"pattern": [
{
"regexp": "^(?!)((?:.*))$",
"kind": "file",
"regexp": ".",
"file": 1,
"message": 1
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "^Building webview for|^\\s*VITE",
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
"beginsPattern": ".",
"endsPattern": "."
}
}
],
-43
View File
@@ -1,48 +1,5 @@
# Cline CLI Changelog
## 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
-15
View File
@@ -346,24 +346,9 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
-281
View File
@@ -1,281 +0,0 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
-42
View File
@@ -23,48 +23,6 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.44",
"version": "3.0.38",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+1 -19
View File
@@ -511,7 +511,6 @@ export class AcpAgent implements Agent {
private async buildConfig(session: SessionState): Promise<Config> {
const cwd = session.cwd || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
// Resolve credentials: env vars take precedence, then session provider.
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
@@ -520,7 +519,6 @@ export class AcpAgent implements Agent {
providerId,
mode: session.currentMode,
});
const cliBuildInfo = getCliBuildInfo();
return {
providerId,
@@ -539,23 +537,7 @@ export class AcpAgent implements Agent {
enableAgentTeams: false,
enableTools: true,
cwd,
workspaceRoot,
extensionContext: {
client: {
name: "cline-acp",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
workspaceName: cwd,
ide: "Terminal Shell",
platform: process.platform,
},
},
workspaceRoot: resolveWorkspaceRoot(cwd),
};
}
}
-367
View File
@@ -1,367 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+2 -3
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,7 +15,6 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -210,7 +209,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
-35
View File
@@ -9,7 +9,6 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -175,40 +174,6 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
-7
View File
@@ -14,7 +14,6 @@ import {
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
@@ -50,8 +49,6 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -340,8 +337,6 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -424,8 +419,6 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
-4
View File
@@ -34,7 +34,6 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -64,7 +63,6 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -90,8 +88,6 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
-3
View File
@@ -9,7 +9,6 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -135,8 +134,6 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
-16
View File
@@ -101,22 +101,6 @@ describe("getInstallationInfo", () => {
});
});
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"];
+1 -6
View File
@@ -118,12 +118,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
// them to ~/.bun/install/global/node_modules/..., so match both.
if (
scriptPath.includes("/.bun/bin") ||
scriptPath.includes("/.bun/install/global/")
) {
if (scriptPath.includes("/.bun/bin")) {
return {
packageManager: PackageManager.BUN,
packageName: DEFAULT_PACKAGE_NAME,
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.2",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
expect(request.model).toBe("cline-pass/glm-5.1");
});
it("uses auth material resolved by provider settings manager", async () => {
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.2",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.2");
expect(request.model).toBe("cline-pass/glm-5.1");
});
});
@@ -228,22 +228,6 @@ export async function getOrCreateSessionId<
sessionId,
metadata: {
transport: input.transport,
// Delivery descriptor for this connector thread. Lets the
// agent-facing schedule_task tool (deliverTo: "connector") post a
// scheduled run's result back into this thread, reusing the same
// per-adapter delivery path as user-typed /schedule.
delivery: {
adapter: input.transport,
threadId: input.thread.id,
bindingKey: input.thread.id,
...(input.thread.channelId
? { channelId: input.thread.channelId }
: {}),
...(threadState.participantKey
? { participantKey: threadState.participantKey }
: {}),
...(input.hookBotUserName ? { userName: input.hookBotUserName } : {}),
},
...input.sessionMetadata,
...(remoteConfigMetadata ?? {}),
...(threadState.participantKey
+1 -76
View File
@@ -158,9 +158,8 @@ vi.mock("./runtime/run-interactive", () => {
});
vi.mock("./utils/session", () => sessionMocks);
vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", async () => {
vi.mock("@cline/core", () => {
return {
...(await vi.importActual("@cline/core")),
resolveProviderConfig: llmMocks.resolveProviderConfig,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
@@ -1014,80 +1013,6 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("identifies saved Cline accountId for telemetry before runtime events", async () => {
// CLINE-2406: when persisted Cline auth includes an accountId, the
// runtime path must call identifyTelemetryAccount(accountContext) so
// subsequent task.* and workspace.* events carry user_id.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: { accountId: "usr-abc-123", refreshToken: "rt-token" },
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
expect.objectContaining({
id: "usr-abc-123",
provider: "cline",
}),
);
});
it("does not call identifyTelemetryAccount in runtime path when no saved Cline accountId", async () => {
// CLINE-2406: when no persisted accountId is found (anonymous/unauthenticated),
// identifyTelemetryAccount should not be called from the runtime path.
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
// no auth / no accountId
};
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue(
clineSettings,
);
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "cline",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("does not call identifyTelemetryAccount from runtime path when provider is not cline", async () => {
// CLINE-2406: identity identification from saved settings only applies
// to Cline-provider sessions; other providers use different auth flows.
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "openrouter",
model: "openai/gpt-5",
});
authMocks.normalizeProviderId.mockImplementation(
(providerId?: string) => providerId ?? "openrouter",
);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(telemetryMocks.identifyTelemetryAccount).not.toHaveBeenCalled();
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
+1 -40
View File
@@ -15,7 +15,6 @@ import {
getPreferredKanbanInstaller,
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
@@ -47,7 +46,6 @@ import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
getCliTelemetryService,
identifyTelemetryAccount,
} from "./utils/telemetry";
import type { Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
@@ -928,17 +926,6 @@ export async function runCli(): Promise<void> {
runAgent,
} = await loadCliRuntimeModules();
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
// session-scoped logger can't reach them.
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
component: "main",
});
coreServer.setSdkLogger(loggerAdapter.core);
const userInstructionService = createUserInstructionConfigService({
skills: {
workspacePath: workspaceRoot,
@@ -975,25 +962,6 @@ export async function runCli(): Promise<void> {
);
let selectedProviderSettings =
providerSettingsManager.getProviderSettings(provider);
// Apply locally persisted Cline account identity so subsequent events
// (task.*, workspace.initialized) carry user_id when available.
// Note: user.extension_activated fires anonymously earlier in startup
// and cannot be retroactively updated; this is by design for
// lightweight subcommand and pre-auth CLI flows. See CLINE-2406.
if (provider === "cline") {
const savedAuth = selectedProviderSettings?.auth;
if (savedAuth?.accountId) {
identifyTelemetryAccount({
id: savedAuth.accountId,
provider: "cline",
organizationId: savedAuth.organizationId,
organizationName: savedAuth.organizationName,
memberId: savedAuth.memberId,
});
}
}
const persistedApiKey = getPersistedProviderApiKey(
provider,
selectedProviderSettings,
@@ -1061,7 +1029,6 @@ export async function runCli(): Promise<void> {
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
const cliBuildInfo = getCliBuildInfo();
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1112,13 +1079,7 @@ export async function runCli(): Promise<void> {
cwd,
workspaceRoot,
extensionContext: {
client: {
name: "cline-cli",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
client: { name: "cline-cli" },
workspace: {
rootPath: workspaceRoot,
cwd,
@@ -106,7 +106,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(400_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
@@ -130,7 +130,7 @@ describe("compactInteractiveMessages", () => {
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
it("falls back to legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -138,7 +138,7 @@ describe("compactInteractiveMessages", () => {
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(360_000);
expect(context.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
+10 -10
View File
@@ -61,15 +61,11 @@ export async function compactInteractiveMessages(input: {
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const compactionModelInfo = modelInfo
? {
...modelInfo,
id: modelInfo.id ?? input.config.modelId,
}
: {
id: input.config.modelId,
maxInputTokens: FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS,
};
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
modelInfo?.maxInputTokens ??
modelInfo?.contextWindow ??
FALLBACK_MANUAL_COMPACTION_MAX_INPUT_TOKENS;
const compact = createContextCompactionPrepareTurn(
{
providerConfig: resolveCompactionProviderConfig(
@@ -110,7 +106,11 @@ export async function compactInteractiveMessages(input: {
model: {
id: input.config.modelId,
provider: input.config.providerId,
info: compactionModelInfo,
info: {
...(modelInfo ?? {}),
id: modelInfo?.id ?? input.config.modelId,
maxInputTokens: maxInputTokens,
},
},
});
if (!result?.messages) {
+32 -7
View File
@@ -107,13 +107,38 @@ export async function sendTurnWithActModeContinuation<
};
}
// The tracker moved to @cline/shared so the VSCode extension can share the
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
// import surface stable.
export {
createModeSwitchNoticeTracker,
type ModeSwitchNotice,
} from "@cline/shared";
export type ModeSwitchNotice = {
from: InteractiveUiMode;
to: InteractiveUiMode;
};
/**
* Tracks a user-initiated mode switch so the next user message can carry a
* <mode_notice> marking it. Only UI toggles are recorded: the model-initiated
* switch_to_act_mode path already announces itself via the continuation
* prompt. A round trip (plan -> act -> plan before sending anything) cancels
* out, since the mode the model last saw never effectively changed.
*/
export function createModeSwitchNoticeTracker() {
let pending: ModeSwitchNotice | null = null;
return {
record(from: InteractiveUiMode, to: InteractiveUiMode): void {
if (from === to) {
return;
}
if (pending) {
pending = pending.from === to ? null : { from: pending.from, to };
return;
}
pending = { from, to };
},
consume(): ModeSwitchNotice | null {
const notice = pending;
pending = null;
return notice;
},
};
}
export async function applyInteractiveModeConfig(input: {
config: Config;
@@ -157,7 +157,6 @@ function makeManager() {
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(async () => {}),
pendingPrompts: {
update: vi.fn(),
},
@@ -815,83 +814,6 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
const manager = makeManager();
const config = {
...createConfig(),
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
};
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "hello" }] },
];
manager.readMessages.mockResolvedValue(messages);
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
}),
}),
);
config.providerId = "openai-compatible";
config.modelId = "custom-model";
config.apiKey = "new-key";
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "session-1",
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
}),
initialMessages: messages,
}),
);
});
it("updates the active session connection in place without restarting", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.updateCurrentSessionConnection({
providerId: "openai",
modelId: "codex-test",
});
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
providerId: "openai",
modelId: "codex-test",
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(runtime.getActiveSessionId()).toBe("session-1");
});
it("does not reuse the session id when restarting empty", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartEmpty();
expect(manager.start).toHaveBeenCalledTimes(2);
const secondStart = manager.start.mock.calls[1]?.[0] as {
config?: { sessionId?: string };
};
expect(secondStart?.config?.sessionId).toBeUndefined();
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
@@ -49,9 +49,6 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
export type SessionConnectionUpdate = Parameters<
CliCore["updateSessionConnection"]
>[1];
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
@@ -213,18 +210,12 @@ export function createInteractiveSessionRuntime(input: {
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
// Restarting an old session associate with this ID,
// For continuing the same conversation, e.g. after a config change.
sessionId?: string,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
config: {
...buildSessionConfig(),
...(sessionId ? { sessionId } : {}),
},
config: buildSessionConfig(),
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
@@ -420,51 +411,43 @@ export function createInteractiveSessionRuntime(input: {
});
};
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
): Promise<void> => {
// Config-only restarts (model/mode/account changes) continue the same
// conversation, so they must keep the session id — otherwise each
// restart mints a new session history entry for the same conversation.
const reuseSessionId = options?.preserveSessionId
? activeSessionId || undefined
: undefined;
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
// Publish the restart as the in-flight startup. Teardown leaves a window
// with no active session, and without this barrier a concurrent
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
// reads that window as "no session" and boots an empty session that then
// races the restarted one for the active slot.
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
reuseSessionId,
);
})().catch((error) => {
startupError = error;
throw error;
});
startupPromise = restart;
try {
await restart;
} finally {
// Restore the pre-restart steady state (startupPromise unset) so a
// failed restart stays retryable by the next ensureReady(). A newer
// startup that already replaced the barrier is left alone.
if (startupPromise === restart) {
startupPromise = undefined;
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
// Publish the restart as the in-flight startup. Teardown leaves a window
// with no active session, and without this barrier a concurrent
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
// reads that window as "no session" and boots an empty session that then
// races the restarted one for the active slot.
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
);
})().catch((error) => {
startupError = error;
throw error;
});
startupPromise = restart;
try {
await restart;
} finally {
// Restore the pre-restart steady state (startupPromise unset) so a
// failed restart stays retryable by the next ensureReady(). A newer
// startup that already replaced the barrier is left alone.
if (startupPromise === restart) {
startupPromise = undefined;
}
}
}
};
};
const restartWithCurrentMessages = async (): Promise<void> => {
const [{ messages, status }, compactionState] = await Promise.all([
@@ -490,24 +473,9 @@ export function createInteractiveSessionRuntime(input: {
systemPrompt: compactionState?.system_prompt,
})
: undefined,
{ preserveSessionId: true },
);
};
const updateCurrentSessionConnection = async (
update: SessionConnectionUpdate,
): Promise<void> => {
await ensureReady();
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
// No live session to update; the next startup builds its config from
// the already-mutated CLI config, so nothing else is needed.
return;
}
await manager.updateSessionConnection(sessionId, update);
};
const restartEmpty = async (): Promise<void> => {
await restartWithMessages([]);
};
@@ -641,22 +609,7 @@ export function createInteractiveSessionRuntime(input: {
})
: undefined,
);
// Report carried context from what the new session actually accepted:
// the host can reject the inherited state (e.g. stale anchor), and the
// UI must not claim a carry-over that did not happen.
const acceptedState = projectedMessages
? await readCompactionState(activeSessionId)
: undefined;
return {
forkedFromSessionId,
newSessionId: activeSessionId,
carriedWorkingContext: acceptedState
? {
workingContextMessages: acceptedState.messages.length,
canonicalMessages: messages.length,
}
: undefined,
};
return { forkedFromSessionId, newSessionId: activeSessionId };
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
@@ -887,7 +840,6 @@ export function createInteractiveSessionRuntime(input: {
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
updateCurrentSessionConnection,
resumeSession,
forkCurrentSession,
compactCurrentSession,
+26 -4
View File
@@ -9,6 +9,23 @@ import {
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
import { isImagePath, loadImageAsDataUrl } from "../utils/image-attachments";
const MODE_TAG_INSTRUCTIONS = `# Plan / Act Modes
User messages arrive wrapped in a <user_input mode="..."> tag. The mode attribute is the interaction mode the user was in when they sent that message: "plan" means plan-mode constraints applied (explore, analyze, and align on a plan -- no edits or state-changing commands), while "act" (or "yolo") means implementation was allowed. If the mode attribute changes between messages, the user switched modes -- the newest message's mode is what governs right now, regardless of what earlier messages allowed. A <mode_notice> block inside a message marks exactly when such a switch happened.`;
const PLAN_MODE_INSTRUCTIONS = `# Plan Mode
You are in Plan mode. Your role is to explore, analyze, and plan -- not to execute.
- Read files, search the codebase, and gather context to understand the problem
- Ask clarifying questions when requirements are ambiguous
- Present your plan as a structured outline with clear steps
- Explain tradeoffs between different approaches when they exist
- Do NOT edit files, write code, run destructive commands, or make any changes
- Do NOT implement anything -- focus on understanding and alignment first
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`;
export async function resolveSystemPrompt(input: {
cwd: string;
explicitSystemPrompt?: string;
@@ -17,10 +34,15 @@ export async function resolveSystemPrompt(input: {
mode?: AgentMode;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
// Mode-tag and plan-mode instructions are appended by the shared prompt
// builder itself (see MODE_TAG_INSTRUCTIONS / PLAN_MODE_INSTRUCTIONS in
// @cline/shared), so only the caller-specific rules are merged here.
const rules = mergeRulesForSystemPrompt(undefined, input.rules);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
// Both modes get the mode-tag explanation: after a switch, the transcript
// still contains messages tagged with the other mode.
rules = rules
? `${rules}\n\n${MODE_TAG_INSTRUCTIONS}`
: MODE_TAG_INSTRUCTIONS;
if (input.mode === "plan") {
rules = `${rules}\n\n${PLAN_MODE_INSTRUCTIONS}`;
}
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: input.cwd,
-153
View File
@@ -43,15 +43,6 @@ const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
"ClinePass limit reached",
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
].join("\n");
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
@@ -74,30 +65,6 @@ vi.mock("@cline/core", () => ({
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -802,126 +769,6 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_LIMIT_MESSAGE,
);
});
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+2 -72
View File
@@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
resolveReasoningForModelChange,
} from "./run-interactive";
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
@@ -42,69 +38,3 @@ describe("resolveReasoningForModelChange", () => {
).toEqual({ enabled: true, effort: "medium" });
});
});
describe("applyInteractiveModelChange", () => {
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
const config = {
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
thinking: undefined,
reasoningEffort: undefined,
} as Config;
const getProviderSettings = vi.fn(() => ({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible" as const,
protocol: "openai-chat" as const,
model: "old-model",
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
const updateCurrentSessionConnection = vi.fn(async () => {});
await applyInteractiveModelChange({
config,
providerSettingsManager: {
getProviderSettings,
saveProviderSettings,
},
sessionRuntime: {
ensureReady,
restartWithCurrentMessages,
updateCurrentSessionConnection,
},
});
expect(saveProviderSettings).toHaveBeenCalledWith({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible",
protocol: "openai-chat",
model: "custom-model",
});
expect(ensureReady).toHaveBeenCalledOnce();
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
providerId: "openai-compatible",
modelId: "custom-model",
});
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
);
});
});
+18 -50
View File
@@ -82,51 +82,6 @@ export function resolveReasoningForModelChange(
return existing.reasoning;
}
export async function applyInteractiveModelChange(input: {
config: Config;
providerSettingsManager: Pick<
ProviderSettingsManager,
"getProviderSettings" | "saveProviderSettings"
>;
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
| "ensureReady"
| "restartWithCurrentMessages"
| "updateCurrentSessionConnection"
>;
}): Promise<void> {
const { config, providerSettingsManager, sessionRuntime } = input;
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
// Provider changes affect more than the model connection: startup resolves
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
// the runtime with the existing transcript so all of that state changes
// together. restartWithCurrentMessages preserves the session ID.
await sessionRuntime.restartWithCurrentMessages();
// A same-ID restart reuses the existing manifest. Sync its connection label
// after the fully configured runtime is live so session history reflects the
// provider/model that will handle subsequent turns.
await sessionRuntime.updateCurrentSessionConnection({
providerId: config.providerId,
modelId: config.modelId,
});
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -732,12 +687,25 @@ export async function runInteractive(
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: () =>
applyInteractiveModelChange({
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerSettingsManager,
sessionRuntime,
}),
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
await sessionRuntime.restartWithCurrentMessages();
},
onSessionRestart: async () => {
await sessionRuntime.ensureReady();
await sessionRuntime.restartEmpty();
-33
View File
@@ -151,38 +151,6 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -215,7 +183,6 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+1 -96
View File
@@ -5,11 +5,9 @@ import { useEffect, useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -25,7 +23,6 @@ import {
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
@@ -134,7 +131,7 @@ function formatToolParams(
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={`${i}:${f.path}`}>
<span key={f.path}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -422,86 +419,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function CompactionDividerRow(props: {
entry: Extract<ChatEntry, { kind: "compaction" }>;
}) {
const { entry } = props;
const { width: terminalWidth } = useTerminalDimensions();
const inProgress = entry.status === "started";
const labelColor = inProgress
? "cyan"
: entry.status === "failed"
? "red"
: entry.status === "cancelled" || entry.status === "skipped"
? "gray"
: "cyan";
const label = `${formatCompactionDividerLabel(entry)}`;
// Fill the remaining line with a plain rule instead of a flexGrow bordered
// box: a single fixed-content text row keeps the renderer's diffing stable.
const ruleWidth = Math.max(2, Math.min(40, terminalWidth - label.length - 8));
return (
<box flexDirection="row">
{inProgress ? (
<box width={2}>
<spinner name="dots" color={labelColor} />
</box>
) : (
<text fg="gray" content="── " />
)}
<text fg={labelColor} selectable content={label} />
<text fg="gray" content={` ${"─".repeat(ruleWidth)}`} />
</box>
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -617,15 +534,6 @@ export function ChatEntryView(props: {
/>
);
}
if (isClinePassLimitErrorMessage(entry.text)) {
return (
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -649,9 +557,6 @@ export function ChatEntryView(props: {
</box>
);
case "compaction":
return <CompactionDividerRow entry={entry} />;
case "done": {
const parts: string[] = [];
if (entry.elapsed) parts.push(`${entry.elapsed}s`);
@@ -1,50 +1,8 @@
import {
getProviderAuthStorageId,
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
/**
* Persist a manually entered API key for an OAuth-capable provider — the
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
* stale token would otherwise keep winning over the manual key.
*
* The key is written both to the provider's auth storage entry (cline-pass
* stores credentials under "cline") and to the provider's own entry: settings
* resolution lets a direct entry shadow the storage entry, and provider
* switching copies merged settings (including auth) into direct entries, so
* both must be updated for the manual key to reliably take effect.
*/
export function saveManualProviderApiKey(
manager: ProviderSettingsManager,
providerId: string,
apiKey: string,
): void {
// Empty strings delete these keys from the stored auth object.
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
saveLocalProviderSettings(manager, {
providerId: storageProviderId,
apiKey,
auth: clearedAuth,
});
if (
providerId !== storageProviderId &&
manager.read().providers[providerId]
) {
saveLocalProviderSettings(manager, {
providerId,
apiKey,
auth: clearedAuth,
});
}
}
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
@@ -1,16 +1,5 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ProviderSettingsManager } from "@cline/core";
import { afterEach, describe, expect, it } from "vitest";
import {
getPersistedProviderApiKey,
isProviderConfigured,
} from "../../../utils/provider-auth";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
import { describe, expect, it } from "vitest";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
@@ -27,99 +16,3 @@ describe("buildClinePassSubscriptionPageUrl", () => {
);
});
});
describe("saveManualProviderApiKey", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function createManager(): ProviderSettingsManager {
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
tempDirs.push(dir);
return new ProviderSettingsManager({
filePath: join(dir, "providers.json"),
});
}
it("clears stored OAuth tokens so the manual key takes effect", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
accountId: "acct_123",
},
});
saveManualProviderApiKey(manager, "cline", "manual-api-key");
const settings = manager.getProviderSettings("cline");
expect(settings?.apiKey).toBe("manual-api-key");
expect(settings?.auth?.accessToken).toBeUndefined();
expect(settings?.auth?.refreshToken).toBeUndefined();
expect(settings?.auth?.accountId).toBe("acct_123");
expect(getPersistedProviderApiKey("cline", settings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline", settings)).toBe(true);
});
it("saves cline-pass keys to the shared cline auth storage entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
// cline-pass inherits auth storage from the "cline" entry, so the key
// must land there and the stale tokens must be gone for both providers.
const clineSettings = manager.getProviderSettings("cline");
expect(clineSettings?.apiKey).toBe("manual-api-key");
expect(clineSettings?.auth?.accessToken).toBeUndefined();
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
});
it("clears stale credentials copied into a direct cline-pass entry", () => {
const manager = createManager();
manager.saveProviderSettings({
provider: "cline",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
// Provider switching copies the merged settings (including auth) into
// a direct cline-pass entry, which shadows the shared "cline" entry.
manager.saveProviderSettings({
provider: "cline-pass",
apiKey: "stale-copied-key",
auth: {
accessToken: "stale-access-token",
refreshToken: "stale-refresh-token",
},
});
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
const clinePassSettings = manager.getProviderSettings("cline-pass");
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
"manual-api-key",
);
});
});
@@ -37,10 +37,7 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import {
buildClinePassSubscriptionPageUrl,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -727,27 +724,13 @@ export function CodexCliStatusContent(
);
}
/**
* Resolves `true` on successful login, `"use_api_key"` when the user opts
* into manual API key entry (only offered with `allowApiKeyFallback`).
*/
export type OAuthLoginResult = boolean | "use_api_key";
export function OAuthLoginContent(
props: ChoiceContext<OAuthLoginResult> & {
props: ChoiceContext<boolean> & {
providerId: string;
providerName: string;
allowApiKeyFallback?: boolean;
},
) {
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
allowApiKeyFallback,
} = props;
const { resolve, dismiss, dialogId, providerId, providerName } = props;
const [mode, setMode] = useState<"browser" | "device">(
providerId === "cline" ? "device" : "browser",
);
@@ -880,19 +863,9 @@ export function OAuthLoginContent(
if (key.name === "escape") {
cancelAuthAttempt();
dismiss();
return;
}
if (key.name === "k" && allowApiKeyFallback) {
cancelAuthAttempt();
resolve("use_api_key");
}
}, dialogId);
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
<box flexDirection="column" paddingX={1} gap={1}>
@@ -919,8 +892,8 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
<text fg="gray">
<em>Esc to cancel</em>
</text>
</box>
);
@@ -942,83 +915,8 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
</text>
</box>
);
}
/**
* Manual API key entry for OAuth-capable providers — the escape hatch for
* when OAuth login isn't working. Saving clears any stored OAuth tokens so
* the manual key takes effect (see saveManualProviderApiKey).
*/
export function OAuthApiKeyInputContent(
props: ChoiceContext<boolean> & {
providerId: string;
providerName: string;
providerSettingsManager: ProviderSettingsManager;
},
) {
const {
resolve,
dismiss,
dialogId,
providerId,
providerName,
providerSettingsManager,
} = props;
const [value, setValue] = useState("");
const submit = () => {
const apiKey = value.trim();
if (!apiKey) return;
saveManualProviderApiKey(providerSettingsManager, providerId, apiKey);
resolve(true);
};
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
submit();
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg={palette.act}>
<strong>{providerName}</strong>
</text>
<text fg="gray">
Use an API key from your Cline dashboard instead of OAuth login. This
replaces any saved login tokens.
</text>
<box flexDirection="column">
<text fg="gray">API key</text>
<box
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<input
value={value}
onInput={setValue}
placeholder="Paste your API key"
flexGrow={1}
focused
/>
</box>
</box>
<text fg="gray">
<em>Enter to save, Esc to go back</em>
<text fg="gray">
<em>Esc to cancel</em>
</text>
</box>
);
@@ -1,106 +0,0 @@
import type {
ClineRecommendedModel,
ClineRecommendedModelsData,
} from "@cline/core";
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: ClineModelPickerTier;
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
ClineModelPickerTier,
string
> = {
recommended: "Recommended",
subscribed: "Subscribed",
free: "Free",
};
// Featured entries for the sectioned picker, keyed by provider: cline gets
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
export function buildFeaturedModelEntries(
providerId: string,
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
return providerId === "cline-pass"
? buildClinePassModelEntries(data)
: buildClineModelEntries(data);
}
function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
// Shown under the Free section header when picking a model for ClinePass
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
"Try with limited usage, separate from ClinePass quota.";
// ClinePass shows the subscription's models plus the Cline free models — both
// providers hit the same Cline API, so free models are selectable in place
// (they ride usage billing at $0 instead of the subscription quota).
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
// the ClinePass catalog contains exactly these two buckets, so the sections
// already list every selectable model. An empty clinePass bucket means the
// fetch fell back to the bundled list (which has no pass models) — without an
// escape into the full catalog a subscriber could only pick free models, so
// browse-all comes back in that degraded mode.
function buildClinePassModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.clinePass) {
entries.push({ kind: "model", model: m, tier: "subscribed" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
if (data.clinePass.length === 0) {
entries.push({ kind: "browse" });
}
return entries;
}
// The quota explainer only makes sense in the ClinePass picker, which is the
// only picker that has a "subscribed" section
export function freeTierDescriptionFor(
entries: ClineModelPickerEntry[],
): string | undefined {
const isClinePassPicker = entries.some(
(entry) => entry.kind === "model" && entry.tier === "subscribed",
);
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
}
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
// disambiguate them from their paid twins. Inside the sectioned pickers the
// Free header already says it, so the markers are redundant — but keep them in
// flat lists (e.g. browse-all), where both variants appear side by side.
export function stripFreeMarker(displayName: string): string {
return displayName
.replace(/\s*\(free\)\s*$/i, "")
.replace(/:free$/i, "")
.trim();
}
@@ -1,98 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildFeaturedModelEntries,
CLINE_PASS_FREE_SECTION_DESCRIPTION,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
describe("cline model picker entries", () => {
it("builds Recommended/Free sections for the cline provider", () => {
const entries = buildFeaturedModelEntries("cline", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
});
expect(entries).toEqual([
{
kind: "model",
model: model("anthropic/claude-sonnet-5"),
tier: "recommended",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("builds Subscribed/Free sections for the cline-pass provider", () => {
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
});
expect(entries).toEqual([
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
{
kind: "model",
model: model("cline-pass/kimi-k2.6"),
tier: "subscribed",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
]);
});
it("adds the browse-all escape when the clinePass bucket is empty", () => {
// The fetch fell back to the bundled list (no pass models); the sections
// alone would leave a subscriber able to pick only free models.
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [],
});
expect(entries).toEqual([
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
const data = {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
};
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
).toBe(undefined);
});
it("strips redundant free markers from display names", () => {
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
"Trinity Large Preview",
);
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
});
});
@@ -1,6 +1,7 @@
// @jsxImportSource @opentui/react
import {
type ClineRecommendedModel,
type ClineRecommendedModelsData,
fetchClineRecommendedModels,
} from "@cline/core";
@@ -8,23 +9,20 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
export {
buildFeaturedModelEntries,
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerBrowse,
type ClineModelPickerEntry,
type ClineModelPickerItem,
type ClineModelPickerTier,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: "recommended" | "free";
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
@@ -41,13 +39,12 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return stripFreeMarker(hit.name);
if (hit?.name) return hit.name;
}
}
const fallback = modelId.includes("/")
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function useClineRecommendedModels() {
@@ -71,6 +68,20 @@ export function useClineRecommendedModels() {
return { data, loading };
}
export function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
@@ -92,7 +103,6 @@ export function ClineModelPicker(props: {
let lastTier: string | null = null;
let isFirstHeader = true;
const rows: ReactNode[] = [];
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -102,20 +112,14 @@ export function ClineModelPicker(props: {
if (entry.kind === "model") {
if (entry.tier !== lastTier) {
lastTier = entry.tier;
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
const label = entry.tier === "recommended" ? "Recommended" : "Free";
rows.push(
<box
key={`tier-${entry.tier}`}
paddingX={1}
marginTop={isFirstHeader ? 0 : 1}
flexDirection="column"
>
<text fg="gray">{label}</text>
{entry.tier === "free" && freeTierDescription && (
<text fg="gray">
<em>{freeTierDescription}</em>
</text>
)}
</box>,
);
isFirstHeader = false;
@@ -3,12 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-picker";
import type { ClineModelPickerEntry } from "./cline-model-picker";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -34,13 +29,12 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return stripFreeMarker(hit.name);
if (hit?.name) return hit.name;
}
}
const fallback = modelId.includes("/")
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function ClineModelSelectorContent(
@@ -68,13 +62,11 @@ export function ClineModelSelectorContent(
key: string;
kind: "header" | "model" | "browse";
label: string;
description?: string;
tags: string[];
isCurrent: boolean;
entryIndex: number;
}[] = [];
let lastTier: string | null = null;
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry) continue;
@@ -84,9 +76,7 @@ export function ClineModelSelectorContent(
rows.push({
key: `tier-${entry.tier}`,
kind: "header",
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
description:
entry.tier === "free" ? freeTierDescription : undefined,
label: entry.tier === "recommended" ? "Recommended" : "Free",
tags: [],
isCurrent: false,
entryIndex: -1,
@@ -166,18 +156,8 @@ export function ClineModelSelectorContent(
if (row.kind === "header") {
const isFirst = idx === 0;
return (
<box
key={row.key}
paddingX={1}
marginTop={isFirst ? 0 : 1}
flexDirection="column"
>
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
<text fg="gray">{row.label}</text>
{row.description && (
<text fg="gray">
<em>{row.description}</em>
</text>
)}
</box>
);
}
@@ -3,7 +3,6 @@ import type { OpenConfigOptions } from "./use-config-panel";
export interface LocalSlashCommandActionInput {
name: string;
isRunning: boolean;
openAccount: () => void;
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
@@ -47,12 +46,7 @@ export function runLocalSlashCommandAction(
return true;
}
if (normalized === "compact") {
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
// shared running state.
if (!input.isRunning) {
input.runCompact();
}
input.runCompact();
return true;
}
if (normalized === "fork") {
@@ -7,10 +7,7 @@ import {
type AccountDialogAction,
AccountDialogContent,
} from "../components/dialogs/account-dialog";
import {
OAuthLoginContent,
type OAuthLoginResult,
} from "../components/dialogs/provider-picker";
import { OAuthLoginContent } from "../components/dialogs/provider-picker";
import type { OpenModelSelectorOptions } from "./use-model-selector";
export function useAccountDialog(opts: {
@@ -63,14 +60,14 @@ export function useAccountDialog(opts: {
return;
}
if (action === "login") {
const saved = await dialog.choice<OAuthLoginResult>({
const saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
content: (ctx: ChoiceContext<boolean>) => (
<OAuthLoginContent {...ctx} providerId="cline" providerName="Cline" />
),
});
if (saved === true) {
if (saved) {
await onAccountChange?.();
await openAccountDialog();
return;
+3 -87
View File
@@ -6,14 +6,13 @@ import type {
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
formatToolOutput,
truncate,
} from "../../utils/helpers";
import type { ChatEntry, InlineStream, TuiProps } from "../types";
import { parseCompactionNoticeMetadata } from "../utils/compaction-status";
interface AgentEventDeps {
appendEntry: (entry: ChatEntry) => void;
@@ -33,7 +32,6 @@ interface AgentEventDeps {
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -47,47 +45,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
verbose,
} = deps;
// Compaction dividers that arrived while an assistant message was still
// streaming. Appending them immediately would split the message in two, so
// they are held until the active content block closes (or the turn ends).
const pendingCompactionEntriesRef = useRef<
Extract<ChatEntry, { kind: "compaction" }>[]
>([]);
const flushPendingCompactionEntries = useCallback(() => {
const pending = pendingCompactionEntriesRef.current;
if (pending.length === 0) return;
pendingCompactionEntriesRef.current = [];
for (const entry of pending) {
if (entry.status !== "started" && openCompactionEntryRef.current) {
updateEntry((current) =>
current.kind === "compaction" && current.status === "started"
? { ...current, ...entry }
: current,
);
openCompactionEntryRef.current = false;
} else {
appendEntry(entry);
if (entry.status === "started") {
openCompactionEntryRef.current = true;
}
}
}
}, [appendEntry, updateEntry]);
const finalizeDanglingCompactionEntry = useCallback(
(status: "failed" | "cancelled") => {
if (!openCompactionEntryRef.current) return;
openCompactionEntryRef.current = false;
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status }
: entry,
);
},
[updateEntry],
);
const closeToolEntry = useCallback(
(event: AgentEvent & { type: "content_end" }) => {
const error = event.error ?? undefined;
@@ -127,11 +84,9 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(true);
setIsStreaming(true);
closeInlineStream();
flushPendingCompactionEntries();
break;
case "iteration_end":
closeInlineStream();
flushPendingCompactionEntries();
break;
case "content_start": {
setIsStreaming(false);
@@ -210,15 +165,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
flushPendingCompactionEntries();
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
@@ -230,40 +181,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
break;
case "notice":
if (event.displayRole === "status") {
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (!compaction) {
closeInlineStream();
}
if (compaction) {
if (activeInlineStreamRef.current) {
// An assistant message is still streaming; appending now
// would split it around the divider. Hold the divider (final
// state until the content block closes, then reconcile it
// with the same open divider atomically.
pendingCompactionEntriesRef.current.push({
kind: "compaction",
...compaction,
});
break;
}
if (compaction.status === "started") {
appendEntry({ kind: "compaction", ...compaction });
openCompactionEntryRef.current = true;
} else if (openCompactionEntryRef.current) {
// Finalize the in-progress divider in place, wherever it
// sits in the transcript.
updateEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, ...compaction }
: entry,
);
openCompactionEntryRef.current = false;
} else {
appendEntry({ kind: "compaction", ...compaction });
}
break;
}
const label = resolveNonCompactionStatusLabel(event);
closeInlineStream();
const label = resolveStatusNoticeLabel(event);
if (label) {
appendEntry({ kind: "status", text: label });
}
@@ -281,7 +200,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -290,8 +208,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
onTurnErrorReported,
verbose,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
],
);
@@ -9,7 +9,6 @@ function makeActions(
overrides: Partial<Omit<LocalSlashCommandActionInput, "name">> = {},
): Omit<LocalSlashCommandActionInput, "name"> {
return {
isRunning: false,
openAccount: vi.fn(),
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
@@ -59,32 +58,6 @@ describe("runLocalSlashCommandAction", () => {
expect(openConfig).toHaveBeenCalledWith({ initialTab: "plugins" });
});
it("does not start compaction while a turn is running", () => {
const runCompact = vi.fn();
const actions = makeActions({ isRunning: true, runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).not.toHaveBeenCalled();
});
it("starts compaction while the session is idle", () => {
const runCompact = vi.fn();
const actions = makeActions({ runCompact });
const handled = runLocalSlashCommandAction({
name: "compact",
...actions,
});
expect(handled).toBe(true);
expect(runCompact).toHaveBeenCalledOnce();
});
it("waits for clear to reset the runtime session", async () => {
let resolveClear: (() => void) | undefined;
const clearConversation = vi.fn(
@@ -9,6 +9,7 @@ import { HelpDialogContent } from "../components/dialogs/help-dialog";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { AppView, TuiProps } from "../types";
import { formatCompactionStatus } from "../utils/compaction-status";
import { hydrateSessionMessages } from "../utils/hydrate-messages";
import type { LocalSlashCommandInvocation } from "../utils/skill-command-input";
import { HistoryDialogContent } from "../views/history-view";
@@ -115,42 +116,21 @@ export function useLocalCommandActions(input: {
}, [dialog, refocusTextarea, termHeight]);
const runCompact = useCallback(async () => {
session.setIsRunning(true);
session.appendEntry({
kind: "compaction",
compactionMode: "manual",
status: "started",
kind: "status",
text: "Compacting context...",
});
try {
const result = await onCompact();
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? {
...entry,
status: result.compacted ? "completed" : "skipped",
messagesBefore: result.messagesBefore,
messagesAfter:
result.workingContextMessagesAfter ?? result.messagesAfter,
}
: entry,
);
session.updateLastEntry(() => ({
kind: "status",
text: formatCompactionStatus(result),
}));
} catch (error) {
const cancelled =
error instanceof Error &&
(error.name === "AbortError" || /abort/i.test(error.message));
session.updateLastEntry((entry) =>
entry.kind === "compaction" && entry.status === "started"
? { ...entry, status: cancelled ? "cancelled" : "failed" }
: entry,
);
if (!cancelled) {
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
} finally {
session.setIsRunning(false);
session.appendEntry({
kind: "error",
text: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
});
}
}, [onCompact, session]);
@@ -179,15 +159,6 @@ export function useLocalCommandActions(input: {
kind: "status",
text: `Forked into new session ${result.newSessionId}. This is now the active session. Use /history to switch sessions.`,
}));
if (result.carriedWorkingContext) {
session.appendEntry({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: result.carriedWorkingContext.canonicalMessages,
messagesAfter: result.carriedWorkingContext.workingContextMessages,
});
}
} else {
session.updateLastEntry(() => ({
kind: "error",
@@ -210,7 +181,6 @@ export function useLocalCommandActions(input: {
}
return runLocalSlashCommandAction({
name: resolved.name,
isRunning: session.isRunning,
invocation,
openAccount,
openConfig,
@@ -239,7 +209,6 @@ export function useLocalCommandActions(input: {
openSkills,
runCompact,
runFork,
session.isRunning,
slashCommandRegistry,
],
);
+6 -104
View File
@@ -6,7 +6,6 @@ import {
refreshProviderModelsFromSource,
resolveProviderConfig,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
@@ -22,14 +21,12 @@ import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
ProviderConfigInputContent,
ProviderPickerContent,
UseExistingOrReconfigureContent,
} from "../components/dialogs/provider-picker";
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
import {
BROWSE_ALL_ACTION,
ClineModelSelectorDialogContent,
@@ -82,51 +79,6 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
/**
* Ask an OpenAI-compatible endpoint for its model list (`GET <baseUrl>/models`)
* using the provider's stored API key and headers, mirroring the extension's
* refreshOpenAiModels handler. Returns [] on any failure so callers fall back
* to manual model-id entry.
*/
async function fetchOpenAiCompatibleModelIds(
providerId: string,
): Promise<string[]> {
try {
const manager = new ProviderSettingsManager();
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const baseUrl = config?.baseUrl?.trim().replace(/\/+$/, "");
if (!baseUrl || !URL.canParse(baseUrl)) return [];
const headers: Record<string, string> = { ...(config?.headers ?? {}) };
const apiKey = config?.apiKey?.trim();
if (
apiKey &&
!Object.keys(headers).some((h) => h.toLowerCase() === "authorization")
) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${baseUrl}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return [];
const payload = (await response.json()) as { data?: unknown };
const list = Array.isArray(payload?.data) ? payload.data : [];
const ids = list
.map((model) => {
const id = (model as { id?: unknown } | null)?.id;
return typeof id === "string" ? id.trim() : "";
})
.filter(Boolean);
return [...new Set(ids)];
} catch {
return [];
}
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
@@ -179,23 +131,6 @@ async function runProviderChange(
);
const existingSettings = manager.getProviderSettings(newProviderId);
// Manual API key entry is the escape hatch for when OAuth login isn't
// working; only the Cline providers accept a dashboard API key.
const supportsManualApiKey = isClineProvider(newProviderId);
const openManualApiKeyDialog = async (): Promise<boolean | undefined> =>
await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<OAuthApiKeyInputContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
providerSettingsManager={manager}
/>
),
});
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
@@ -230,22 +165,17 @@ async function runProviderChange(
if (needsAuth) {
let saved: boolean | undefined;
if (isOAuthProvider(newProviderId)) {
const loginResult = await dialog.choice<OAuthLoginResult>({
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<OAuthLoginResult>) => (
content: (ctx: ChoiceContext<boolean>) => (
<OAuthLoginContent
{...ctx}
providerId={newProviderId}
providerName={displayName}
allowApiKeyFallback={supportsManualApiKey}
/>
),
});
saved =
loginResult === "use_api_key"
? await openManualApiKeyDialog()
: loginResult;
} else if (isOpenAICodexCliProvider(newProviderId)) {
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
@@ -345,28 +275,12 @@ export function useModelSelector(opts: {
config.knownModels as Record<string, Llms.ModelInfo>,
);
let providerDisplayName = config.providerId;
let endpointModelOptions: ModelOption[] = [];
const refreshProviderContext = async () => {
modelOptions = buildModelOptions(
config.knownModels as Record<string, Llms.ModelInfo>,
);
providerDisplayName = await getProviderDisplayName(config.providerId);
// Free-text providers (openai-compatible) can still suggest model
// ids when their endpoint answers /models; otherwise they keep the
// manual input.
endpointModelOptions = usesModelIdInput(config.providerId)
? buildModelOptions(
Object.fromEntries(
(await fetchOpenAiCompatibleModelIds(config.providerId)).map(
(id) => [id, { id, name: id }],
),
),
)
: [];
if (endpointModelOptions.length > 0) {
modelOptions = endpointModelOptions;
}
};
if (!options?.startWithProviderChange) {
@@ -402,10 +316,7 @@ export function useModelSelector(opts: {
let pickingModel = true;
while (pickingModel) {
if (
usesModelIdInput(config.providerId) &&
endpointModelOptions.length === 0
) {
if (usesModelIdInput(config.providerId)) {
const modelId = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -430,13 +341,7 @@ export function useModelSelector(opts: {
continue;
}
if (
config.providerId === "cline" ||
config.providerId === "cline-pass"
) {
// ClinePass gets the same sectioned picker with Subscribed/Free
// sections — free models are selectable while staying on ClinePass
const featuredProviderId = config.providerId;
if (config.providerId === "cline") {
const clineResult = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -446,10 +351,7 @@ export function useModelSelector(opts: {
currentProviderName={providerDisplayName}
knownModels={config.knownModels as Record<string, unknown>}
loadEntries={async () =>
buildFeaturedModelEntries(
featuredProviderId,
await fetchClineRecommendedModels(),
)
buildClineModelEntries(await fetchClineRecommendedModels())
}
/>
),
@@ -1,91 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type TerminalTitleRenderer,
useTerminalTitle,
} from "./use-terminal-title";
const reactMock = vi.hoisted(() => {
const cleanups: Array<() => void> = [];
return {
cleanups,
// Run effect bodies now, but retain their cleanups so each test can move
// the renderer across the native destruction boundary before unmount.
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
const cleanup = effect();
if (cleanup) {
cleanups.push(cleanup);
}
}),
};
});
vi.mock("react", () => ({
useEffect: reactMock.useEffect,
}));
function createTitleRenderer() {
let destroyed = false;
const setTerminalTitle = vi.fn(() => {
if (destroyed) {
throw new Error("setTerminalTitle called after renderer destruction");
}
});
const renderer: TerminalTitleRenderer = {
get isDestroyed() {
return destroyed;
},
setTerminalTitle,
};
return {
destroy: () => {
destroyed = true;
},
renderer,
setTerminalTitle,
};
}
beforeEach(() => {
reactMock.cleanups.length = 0;
reactMock.useEffect.mockClear();
});
describe("useTerminalTitle", () => {
it("sets and resets the title while the renderer is active", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
});
it("does not set the title when its effect runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
titleRenderer.destroy();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
});
it("does not reset the title when cleanup runs after renderer destruction", () => {
const titleRenderer = createTitleRenderer();
useTerminalTitle(titleRenderer.renderer, "Cline");
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
titleRenderer.destroy();
for (const cleanup of reactMock.cleanups) {
cleanup();
}
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
});
});
@@ -1,29 +0,0 @@
import { useEffect } from "react";
export interface TerminalTitleRenderer {
readonly isDestroyed: boolean;
setTerminalTitle(title: string): void;
}
export function useTerminalTitle(
renderer: TerminalTitleRenderer,
terminalTitle: string,
): void {
// setTerminalTitle writes into memory owned by the native renderer, so it
// must never run after destroy. React can flush passive effects after the
// renderer's memory has been freed.
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
};
}, [renderer]);
}
-37
View File
@@ -8,9 +8,7 @@ const rendererMock = vi.hoisted(() => ({
defaultBackground: null,
defaultForeground: null,
})),
isDestroyed: false,
on: vi.fn(),
setTerminalTitle: vi.fn(),
}));
const rootMock = vi.hoisted(() => ({
@@ -39,9 +37,7 @@ describe("renderOpenTui", () => {
beforeEach(() => {
destroyHandlers.length = 0;
rendererMock.isDestroyed = false;
rendererMock.destroy.mockReset();
rendererMock.setTerminalTitle.mockReset();
rendererMock.on.mockReset();
rendererMock.on.mockImplementation((event: string, handler: () => void) => {
if (event === "destroy") {
@@ -100,37 +96,4 @@ describe("renderOpenTui", () => {
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
expect(rootMock.unmount).toHaveBeenCalledTimes(1);
});
it("resets the terminal title before destroying the renderer", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
await Promise.resolve();
expect(rendererMock.setTerminalTitle).toHaveBeenCalledWith("");
expect(rendererMock.destroy).toHaveBeenCalledTimes(1);
const titleCallOrder =
rendererMock.setTerminalTitle.mock.invocationCallOrder[0];
const destroyCallOrder = rendererMock.destroy.mock.invocationCallOrder[0];
expect(titleCallOrder).toBeLessThan(destroyCallOrder);
});
it("skips the title reset when the renderer is destroyed before the teardown microtask runs", async () => {
const { renderOpenTui } = await import("./index");
const tui = await renderOpenTui({} as TuiProps);
tui.destroy();
// Simulate OpenTUI's own signal handler destroying the renderer in the
// same dispatch (e.g. an idle SIGTERM fires both our handler and
// OpenTUI's exitHandler before microtasks drain).
rendererMock.isDestroyed = true;
for (const handler of destroyHandlers) {
handler();
}
await Promise.resolve();
expect(rendererMock.setTerminalTitle).not.toHaveBeenCalled();
});
});
-8
View File
@@ -67,14 +67,6 @@ export async function renderOpenTui(
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
// Reset the title while the native renderer is still alive; the
// unmount cleanup in root.tsx skips it once the renderer is destroyed.
// Re-check here: OpenTUI's own signal handlers can destroy the
// renderer between destroy() queuing this microtask and it running
// (e.g. an idle SIGTERM dispatches to both our handler and OpenTUI's).
if (!renderer.isDestroyed) {
renderer.setTerminalTitle("");
}
renderer.destroy();
});
};
+1 -2
View File
@@ -27,7 +27,6 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -196,7 +195,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+9 -2
View File
@@ -53,7 +53,6 @@ import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import type { AppView, TuiProps } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -473,7 +472,15 @@ function App(props: TuiProps) {
};
}, [renderer, showToast]);
useTerminalTitle(renderer, terminalTitle);
useEffect(() => {
renderer.setTerminalTitle(terminalTitle);
}, [renderer, terminalTitle]);
useEffect(() => {
return () => {
renderer.setTerminalTitle("");
};
}, [renderer]);
useEffect(() => {
return () => {
+1 -18
View File
@@ -44,15 +44,6 @@ export type ChatEntry = (
}
| { kind: "error"; text: string }
| { kind: "status"; text: string }
| {
kind: "compaction";
compactionMode: "auto" | "manual" | "inherited";
status: "started" | "completed" | "skipped" | "failed" | "cancelled";
tokensBefore?: number;
tokensAfter?: number;
messagesBefore?: number;
messagesAfter?: number;
}
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
| {
@@ -195,15 +186,7 @@ export interface TuiProps {
onResumeSession: (sessionId: string) => Promise<ResumedSessionResult>;
onCompact: () => Promise<InteractiveCompactionResult>;
onFork: () => Promise<
| {
forkedFromSessionId: string;
newSessionId: string;
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
}
| undefined
{ forkedFromSessionId: string; newSessionId: string } | undefined
>;
getCheckpointData: () => Promise<
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
@@ -1,168 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatCompactionDividerLabel,
formatTokenCount,
parseCompactionNoticeMetadata,
} from "./compaction-status";
describe("parseCompactionNoticeMetadata", () => {
it("extracts a divider entry from a completed auto-compaction notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
reason: "auto_compaction",
phase: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toEqual({
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
});
});
it("extracts a streaming divider entry from a started notice", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "started",
}),
).toEqual({ compactionMode: "auto", status: "started" });
});
it("maps manual compaction notices to manual mode", () => {
expect(
parseCompactionNoticeMetadata({
kind: "manual_compaction",
phase: "completed",
})?.compactionMode,
).toBe("manual");
});
it("maps a benign no-result terminal notice to skipped", () => {
expect(
parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "skipped",
}),
).toEqual({ compactionMode: "auto", status: "skipped" });
});
it("ignores non-compaction metadata", () => {
expect(
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
).toBeUndefined();
expect(
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
).toBeUndefined();
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
});
it("drops non-numeric counters instead of rendering garbage", () => {
const parsed = parseCompactionNoticeMetadata({
kind: "auto_compaction",
phase: "completed",
tokensBefore: "25000",
tokensAfter: Number.NaN,
});
expect(parsed?.tokensBefore).toBeUndefined();
expect(parsed?.tokensAfter).toBeUndefined();
});
});
describe("formatTokenCount", () => {
it("formats counts into compact units", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(6_300)).toBe("6.3k");
expect(formatTokenCount(25_000)).toBe("25k");
expect(formatTokenCount(1_200_000)).toBe("1.2M");
});
});
describe("formatCompactionDividerLabel", () => {
it("includes token and message deltas when present", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "completed",
tokensBefore: 25_101,
tokensAfter: 6_300,
messagesBefore: 142,
messagesAfter: 9,
}),
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
});
it("labels in-progress compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "started",
}),
).toBe("Auto compacting messages");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "started",
}),
).toBe("Compacting messages");
});
it("labels failed and cancelled compaction", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "failed",
}),
).toBe("Compaction failed");
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "cancelled",
}),
).toBe("Compaction cancelled");
});
it("labels skipped compaction without calling it cancelled", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "auto",
status: "skipped",
}),
).toBe("Compaction skipped");
});
it("labels inherited working context from forks and restarts", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "inherited",
status: "completed",
messagesBefore: 60,
messagesAfter: 15,
}),
).toBe("Compacted working context carried over · 60 → 15 messages");
});
it("labels manual compaction and omits missing counters", () => {
expect(
formatCompactionDividerLabel({
kind: "compaction",
compactionMode: "manual",
status: "completed",
}),
).toBe("Context compacted (manual)");
});
});
+1 -98
View File
@@ -1,106 +1,9 @@
import type { ChatEntry, InteractiveCompactionResult } from "../types";
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
import type { InteractiveCompactionResult } from "../types";
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
function asFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
/**
* Extracts a compaction divider entry from a status notice's metadata.
* "started" notices produce a streaming (in-progress) divider; "completed"
* notices produce the final divider with counters. Returns undefined for
* non-compaction notices.
*/
export function parseCompactionNoticeMetadata(
metadata: Record<string, unknown> | undefined,
): Omit<CompactionDividerEntry, "kind"> | undefined {
if (
!metadata ||
(metadata.phase !== "started" &&
metadata.phase !== "completed" &&
metadata.phase !== "skipped")
) {
return undefined;
}
const kind = metadata.kind ?? metadata.reason;
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
return undefined;
}
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
if (metadata.phase === "started") {
return { compactionMode, status: "started" };
}
if (metadata.phase === "skipped") {
return { compactionMode, status: "skipped" };
}
return {
compactionMode,
status: "completed",
tokensBefore: asFiniteNumber(metadata.tokensBefore),
tokensAfter: asFiniteNumber(metadata.tokensAfter),
messagesBefore: asFiniteNumber(metadata.messagesBefore),
messagesAfter: asFiniteNumber(metadata.messagesAfter),
};
}
export function formatTokenCount(count: number): string {
if (count < 1_000) {
return `${count}`;
}
if (count < 1_000_000) {
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
export function formatCompactionDividerLabel(
entry: CompactionDividerEntry,
): string {
if (entry.status === "started") {
return entry.compactionMode === "manual"
? "Compacting messages"
: "Auto compacting messages";
}
if (entry.status === "failed") {
return "Compaction failed";
}
if (entry.status === "cancelled") {
return "Compaction cancelled";
}
if (entry.status === "skipped") {
return "Compaction skipped";
}
const parts: string[] = [
entry.compactionMode === "manual"
? "Context compacted (manual)"
: entry.compactionMode === "inherited"
? "Compacted working context carried over"
: "Context compacted",
];
if (
typeof entry.tokensBefore === "number" &&
typeof entry.tokensAfter === "number"
) {
parts.push(
`${formatTokenCount(entry.tokensBefore)}${formatTokenCount(entry.tokensAfter)} tokens`,
);
}
if (
typeof entry.messagesBefore === "number" &&
typeof entry.messagesAfter === "number"
) {
parts.push(`${entry.messagesBefore}${entry.messagesAfter} messages`);
}
return parts.join(" · ");
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
): string {
-1
View File
@@ -352,7 +352,6 @@ function HistoryListContent({
fg={isSel ? palette.textOnSelection : undefined}
flexGrow={1}
>
{row.source === "schedule" ? "⏰ " : ""}
{title}
</text>
{showCost && cost != null && cost > 0 && (
+16 -36
View File
@@ -29,7 +29,7 @@ import {
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildFeaturedModelEntries,
buildClineModelEntries,
type ClineModelPickerEntry,
useClineRecommendedModels,
} from "../../components/model-selector/cline-model-picker";
@@ -206,14 +206,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const modelList = useSearchableList(modelItems, createCustomModelItem);
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
// Cline featured model picker
const recommended = useClineRecommendedModels();
const clineEntries: ClineModelPickerEntry[] = useMemo(
() =>
recommended.data
? buildFeaturedModelEntries(activeProviderId, recommended.data)
: [],
[recommended.data, activeProviderId],
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
[recommended.data],
);
const [clineModelSelected, setClineModelSelected] = useState(0);
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
@@ -224,37 +221,20 @@ export function useOnboardingController(props: OnboardingControllerProps) {
>(undefined);
useEffect(() => {
// The featured picker serves both cline and cline-pass, so pool reasoning
// support and display names from both catalogs
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
getLocalProviderModels(providerId),
),
).then((results) => {
const ids = new Set<string>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const m of result.value.models) {
getLocalProviderModels("cline")
.then(({ models }) => {
const ids = new Set<string>();
for (const m of models) {
if (m.supportsReasoning) ids.add(m.id);
}
}
setClineModelReasoningIds(ids);
});
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
resolveProviderConfig(providerId),
),
).then((results) => {
const merged: Record<string, unknown> = {};
for (const result of results) {
if (result.status === "fulfilled" && result.value?.knownModels) {
Object.assign(merged, result.value.knownModels);
}
}
if (Object.keys(merged).length > 0) {
setClineKnownModels(merged);
}
});
setClineModelReasoningIds(ids);
})
.catch(() => {});
resolveProviderConfig("cline")
.then((resolved) => {
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
})
.catch(() => {});
}, []);
// Thinking level
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
it("uses the featured Cline model picker only for the Cline provider", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
+1 -2
View File
@@ -207,6 +207,5 @@ export function getOAuthProviderLabel(providerId: string): string {
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
// ClinePass uses the featured picker too, with Subscribed/Free sections
return providerId === "cline" || providerId === "cline-pass";
return providerId === "cline";
}
-9
View File
@@ -14,15 +14,6 @@ export type ChatCommandState = {
export type ForkSessionResult = {
forkedFromSessionId: string;
newSessionId: string;
/**
* Present when the source session had valid compaction state that was
* re-anchored onto the forked session, so the UI can surface why the
* next request is smaller than the canonical history.
*/
carriedWorkingContext?: {
workingContextMessages: number;
canonicalMessages: number;
};
};
export type MuteCommandInput = {
@@ -1,13 +1,10 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -49,22 +46,4 @@ describe("cline-pass-errors", () => {
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
const raw =
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const detail =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClinePassLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Switch to Cline usage-based billing",
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
});
-41
View File
@@ -1,13 +1,10 @@
import {
type ClineSubscriptionPlan,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitError,
isClinePassLimitMessage,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
@@ -27,18 +24,6 @@ export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getCliClinePassLimitMessage(message: string): string {
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
const lines = [
"ClinePass limit reached",
detail,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
];
return lines.filter((line) => line.trim().length > 0).join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -93,27 +78,6 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
);
}
export function getClinePassLimitDetailMessage(
error: unknown,
): string | undefined {
return extractClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
export function isClinePassLimitErrorMessage(error: unknown): boolean {
if (isClinePassLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClinePassLimitError" ||
isClinePassLimitMessage(error.message)
);
}
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
@@ -121,11 +85,6 @@ export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (isClinePassLimitErrorMessage(error)) {
return getCliClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (error instanceof Error) {
return error.message;
}
+3 -3
View File
@@ -26,20 +26,20 @@ describe("CLI compaction mode helpers", () => {
});
it("maps basic and off modes to core compaction config", () => {
const config = createConfig({ enabled: true, preserveRecentTokens: 123 });
const config = createConfig({ enabled: true, maxInputTokens: 123 });
applyCliCompactionMode(config, "basic");
expect(config.compaction).toEqual({
enabled: true,
strategy: "basic",
preserveRecentTokens: 123,
maxInputTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("basic");
applyCliCompactionMode(config, "off");
expect(config.compaction).toEqual({
enabled: false,
preserveRecentTokens: 123,
maxInputTokens: 123,
});
expect(getCliCompactionMode(config)).toBe("off");
});
+1 -63
View File
@@ -1,64 +1,19 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
handleEvent,
handleTeamEvent,
resolveStatusNoticeLabel,
} from "./events";
import { handleEvent, handleTeamEvent } from "./events";
import { setCurrentOutputMode } from "./output";
import type { Config } from "./types";
describe("resolveStatusNoticeLabel", () => {
it("maps compaction status reasons to stable labels", () => {
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "auto-compacting",
reason: "auto_compaction",
} as AgentEvent),
).toBe("auto-compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "manual",
reason: "manual_compaction",
} as AgentEvent),
).toBe("compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction-budget-adjusted",
reason: "compaction_budget_emergency",
} as AgentEvent),
).toBe("context budget adjusted");
});
});
describe("handleEvent text formatting", () => {
let output = "";
let errorOutput = "";
beforeEach(() => {
output = "";
errorOutput = "";
setCurrentOutputMode("text");
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output += String(chunk);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
errorOutput += String(chunk);
return true;
});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
errorOutput += `${args.map(String).join(" ")}\n`;
});
});
it("adds a ⎿ before text that follows a tool block", () => {
@@ -205,23 +160,6 @@ describe("handleEvent text formatting", () => {
expect(output).toContain("── aborted (2 iterations) ──");
});
it("formats ClinePass limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("ClinePass limit reached");
expect(errorOutput).toContain("Switch to Cline usage-based billing");
expect(errorOutput).toContain("--provider cline");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+3 -31
View File
@@ -1,9 +1,4 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import {
formatCompactionDividerLabel,
parseCompactionNoticeMetadata,
} from "../tui/utils/compaction-status";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
c,
@@ -32,31 +27,8 @@ export function resolveStatusNoticeLabel(
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
const compaction = parseCompactionNoticeMetadata(event.metadata);
if (compaction) {
return formatCompactionDividerLabel({ kind: "compaction", ...compaction });
}
return resolveNonCompactionStatusLabel(event);
}
/**
* Label for a status notice already known not to be a compaction notice.
* Callers that have parsed the compaction metadata themselves use this to
* avoid re-parsing.
*/
export function resolveNonCompactionStatusLabel(
event: AgentEvent,
): string | undefined {
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
switch (event.reason) {
case "auto_compaction":
return "auto-compacting";
case "manual_compaction":
return "compacting";
case "compaction_budget_emergency":
return "context budget adjusted";
if (event.reason === "auto_compaction") {
return "auto-compacting";
}
return event.message.trim() || undefined;
}
@@ -204,7 +176,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(formatCliErrorMessage(event.error));
writeErr(event.error.message);
}
break;
case "notice":
@@ -53,37 +53,6 @@ describe("shouldZeroClineFreeModelCost", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("zeros cost of free models selected on the cline-pass provider", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
// subscription (cline-pass/...) models are not in the free bucket
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "cline-pass/glm-5.1",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
+1 -3
View File
@@ -73,9 +73,7 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
// Free models are also selectable on ClinePass — they ride usage billing at $0
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
return false;
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
+6 -8
View File
@@ -42,12 +42,11 @@ export function getPersistedProviderApiKey(
* or endpoint config for the provider. Used by the picker to decide whether
* to offer "Use existing configuration?" before opening the configure dialog.
*
* Treats OAuth providers as configured when an access token or a manually
* saved API key is present (the /settings escape hatch for when OAuth isn't
* working); for everything else, any persisted API key, base URL, or model id
* counts. We don't enforce required fields here the runtime no longer
* pre-flights credentials, so a missing key only matters when the API call
* actually runs and the provider's own auth error is surfaced.
* Treats OAuth providers as configured when an access token is present; for
* everything else, any persisted API key, base URL, or model id counts. We
* don't enforce required fields here the runtime no longer pre-flights
* credentials, so a missing key only matters when the API call actually
* runs and the provider's own auth error is surfaced.
*/
export function isProviderConfigured(
providerId: string,
@@ -55,8 +54,7 @@ export function isProviderConfigured(
): boolean {
if (!settings) return false;
if (isOAuthProvider(providerId)) {
// getPersistedProviderApiKey covers both auth.accessToken and apiKey.
return Boolean(getPersistedProviderApiKey(providerId, settings));
return Boolean(settings.auth?.accessToken?.trim());
}
if (getPersistedProviderApiKey(providerId, settings)) return true;
if (settings.baseUrl?.trim()) return true;
@@ -11,7 +11,6 @@ import {
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
@@ -104,9 +103,7 @@ export async function handleDesktopCommand(
): Promise<unknown> {
if (command === "list_provider_catalog") {
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
return await listLocalProviders(providerSettingsManager);
}
if (command === "list_provider_models") {
const provider = String(args?.provider ?? "").trim();
@@ -168,11 +165,6 @@ export async function handleDesktopCommand(
providerId,
openExternalUrl,
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(providerSettingsManager, providerId, {
tokenSource: "oauth",
});
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
+1 -9
View File
@@ -5,7 +5,6 @@ import {
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
@@ -100,9 +99,7 @@ export async function sendProviderCatalog(
peer: BrowserPeer,
): Promise<void> {
await ensureCustomProvidersLoaded(providerSettingsManager);
const payload = await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
});
const payload = await listLocalProviders(providerSettingsManager);
ctx.send(peer, {
type: "provider_catalog",
providers: payload.providers,
@@ -141,11 +138,6 @@ export async function runProviderOAuthLogin(
normalized,
openExternalUrl,
);
if (saved.provider !== normalized) {
markLocalProviderEnabled(providerSettingsManager, normalized, {
tokenSource: "oauth",
});
}
ctx.send(peer, {
type: "provider_oauth_login_done",
providerId: normalized,
@@ -18,7 +18,6 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -115,7 +114,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -20,13 +20,10 @@ import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
// react-markdown/streamdown pass the hast `Element` here, whose
// `properties` is a broad `Record`. Keep this assignable from that type
// (rather than a narrow `{ metastring?: string }`) so the component stays
// compatible with `Components` regardless of how strict the resolved
// hast/streamdown types are; the metastring value is validated at read time.
node?: {
properties?: Record<string, unknown>;
properties?: {
metastring?: string;
};
};
};
@@ -70,8 +67,7 @@ const MarkdownCode = ({
);
}
const metaValue = node?.properties?.metastring;
const meta = typeof metaValue === "string" ? metaValue : undefined;
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
@@ -258,8 +258,8 @@ export function SettingsView({
? (providers.find((p) => p.id === selectedProviderId) ?? null)
: null;
const usesOAuth = (provider: Provider) =>
provider.capabilities?.includes("oauth") ?? false;
const isOAuthProvider = (id: string) =>
id === "cline" || id === "oca" || id === "openai-codex";
const runOAuthProviderLogin = async (id: string) => {
setOauthSigningProviderId(id);
@@ -386,7 +386,7 @@ export function SettingsView({
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
usesOAuth(selectedProvider)
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
@@ -46,7 +46,6 @@ export interface Provider {
docUrl?: string;
docLabel?: string;
defaultModelId?: string;
capabilities?: string[];
authDescription?: string;
baseUrlDescription?: string;
configFields?: ProviderConfigField[];
-52
View File
@@ -13,60 +13,8 @@ From `apps/examples/desktop-app/`:
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
in the internal [`@cline/ui`](../../../sdk/packages/ui/README.md) workspace
package. Other Cline web surfaces can take only its tokens or opt into the
Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
Tauri desktop bundles are OS-specific, so build each package on the target OS:
- macOS: `bun run package:desktop:mac`
- Windows: `bun run package:desktop:windows`
- Linux: `bun run package:desktop:linux`
The macOS package script refuses to create a shareable package unless Developer ID signing and notarization credentials are configured. This prevents the common Gatekeeper failure where a downloaded unsigned build appears damaged on a teammate's Mac.
Set either `APPLE_CERTIFICATE` or `APPLE_SIGNING_IDENTITY`, plus one notarization credential set before packaging macOS:
- `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`
- `APPLE_API_KEY` or `APPLE_API_KEY_PATH`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
For local-only macOS testing, use `bun run package:desktop:mac --allow-unsigned-mac`. That ad-hoc signs the `.app` and strips quarantine attributes, but it is not suitable for a downloaded build shared with teammates.
### macOS signing & notarization, step by step
One-time keychain setup:
1. Get the **Developer ID Application** identity from your team admin. A `.cer` alone is not enough — you need the private key. If the admin generated the CSR, have them export the identity from Keychain Access as a `.p12` and import it:
`security import BeeCertificates.p12 -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security`
2. If `security find-identity -v -p codesigning` still reports `0 valid identities`, the Apple intermediate CA is missing. Install it:
`curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer && security import DeveloperIDG2CA.cer -k ~/Library/Keychains/login.keychain-db`
3. Re-run `security find-identity -v -p codesigning` — it should now list `Developer ID Application: <Team Name> (<TEAMID>)`. That exact quoted string is your `APPLE_SIGNING_IDENTITY`.
4. Get an **App Store Connect API key** from the admin: the `AuthKey_<KEYID>.p8` file, the Key ID, and the Issuer ID (a UUID from App Store Connect → Users and Access → Integrations). This is used for notarization only — nothing is published.
Per-build:
```bash
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Team Name> (<TEAMID>)"
export APPLE_API_KEY="<KEYID>" # Tauri reads APPLE_API_KEY (the Key ID); APPLE_API_KEY_ID alone silently skips notarization
export APPLE_API_KEY_PATH="/path/to/AuthKey_<KEYID>.p8"
export APPLE_API_ISSUER="<issuer UUID>"
bun run package:desktop:mac
```
The first signing run pops a keychain dialog — enter your macOS login password and click **Always Allow**. Notarization uploads the app to Apple's automated malware scan (typically 210 minutes) and staples the ticket. Artifacts land in `dist/desktop/`; share the `.dmg`. The DMG name takes its version from `src-tauri/tauri.conf.json`, the zip name from `package.json` — bump both.
Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements` reference in `tauri.conf.json`: notarization requires the hardened runtime, which breaks the Bun-compiled sidecar (`SharedArrayBuffer is not defined`, surfacing in-app as "desktop backend endpoint not ready") unless the JIT entitlements are present.
## Runtime Overview
Startup flow:
+5 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.1",
"version": "0.0.0",
"private": true,
"scripts": {
"dev:web": "next dev webview -p 3125 --turbo",
@@ -10,11 +10,6 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -24,9 +19,7 @@
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
@@ -55,9 +48,6 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"autoprefixer": "^10.4.20",
@@ -68,6 +58,7 @@
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"marked": "^17.0.3",
"next": "16.2.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -75,11 +66,11 @@
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"shiki": "^4.0.2",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
@@ -89,7 +80,6 @@
"@tailwindcss/postcss": "^4.2.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"jsdom": "^26.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
@@ -1,311 +0,0 @@
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import { $ } from "bun";
type DesktopPlatform = "mac" | "windows" | "linux";
const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
const VALUE_FLAGS = new Set(["--platform", "--target"]);
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const APP_NAME = "Cline Code";
const APP_ROOT = path.resolve(import.meta.dir, "..");
const BUNDLE_ROOT = path.join(
APP_ROOT,
"src-tauri",
"target",
"release",
"bundle",
);
const PACKAGE_ROOT = path.join(APP_ROOT, "dist", "desktop");
process.chdir(APP_ROOT);
const validateArgs = (): void => {
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (VALUE_FLAGS.has(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for ${arg}`);
}
index += 1;
continue;
}
if (VALID_FLAGS.some((flag) => arg.startsWith(`${flag}=`))) {
continue;
}
if (arg.startsWith("--")) {
const suggestion = VALID_FLAGS.find((flag) => flag.startsWith(arg));
throw new Error(
suggestion
? `unknown option ${arg}. Did you mean ${suggestion}?`
: `unknown option ${arg}`,
);
}
throw new Error(`unexpected argument ${arg}`);
}
};
const getArgValue = (name: string): string | undefined => {
const prefix = `${name}=`;
const inline = process.argv.find((arg) => arg.startsWith(prefix));
if (inline) {
return inline.slice(prefix.length);
}
const index = process.argv.indexOf(name);
if (index >= 0) {
return process.argv[index + 1];
}
return undefined;
};
const hasArg = (name: string): boolean => process.argv.includes(name);
const hostPlatform = (): DesktopPlatform => {
if (process.platform === "darwin") {
return "mac";
}
if (process.platform === "win32") {
return "windows";
}
if (process.platform === "linux") {
return "linux";
}
throw new Error(`unsupported desktop packaging host: ${process.platform}`);
};
const resolveRequestedPlatform = (): DesktopPlatform => {
const platform =
getArgValue("--platform") ?? getArgValue("--target") ?? "current";
if (platform === "current") {
return hostPlatform();
}
if (platform === "mac" || platform === "windows" || platform === "linux") {
return platform;
}
throw new Error(
`unsupported platform "${platform}". Use mac, windows, linux, or current.`,
);
};
const sanitizeName = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "");
const packageVersion = async (): Promise<string> => {
const packageJson = await Bun.file(
path.join(APP_ROOT, "package.json"),
).json();
return String(packageJson.version ?? "0.0.0");
};
const macDistributionCredentialsConfigured = (): boolean => {
const hasCertificate = Boolean(
process.env.APPLE_CERTIFICATE || process.env.APPLE_SIGNING_IDENTITY,
);
const hasAppleIdNotarization = Boolean(
process.env.APPLE_ID &&
process.env.APPLE_PASSWORD &&
process.env.APPLE_TEAM_ID,
);
const hasApiKeyNotarization = Boolean(
(process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_PATH) &&
process.env.APPLE_API_KEY_ID &&
process.env.APPLE_API_ISSUER,
);
return hasCertificate && (hasAppleIdNotarization || hasApiKeyNotarization);
};
const assertCanBuildPlatform = (platform: DesktopPlatform): void => {
const host = hostPlatform();
if (platform !== host) {
throw new Error(
[
`cannot build ${platform} desktop bundles from ${host}.`,
"Tauri desktop bundles are produced on the target OS because the native bundle tools and sidecar binary are platform-specific.",
"Run this same package script on macOS, Windows, and Linux runners to produce all three artifact sets.",
].join("\n"),
);
}
};
const assertMacDistributionReady = (allowUnsignedMac: boolean): void => {
if (hostPlatform() !== "mac") {
return;
}
if (macDistributionCredentialsConfigured() || allowUnsignedMac) {
return;
}
throw new Error(
[
"refusing to create a shareable macOS package without Developer ID signing and notarization credentials.",
"Unsigned quarantined macOS downloads can show as damaged on a teammate's Mac.",
"Set APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY plus notarization credentials before running this script.",
"Supported notarization env sets: APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID, or APPLE_API_KEY/APPLE_API_KEY_PATH + APPLE_API_KEY_ID + APPLE_API_ISSUER.",
"For local-only testing, rerun with --allow-unsigned-mac or ALLOW_UNSIGNED_MAC=1.",
].join("\n"),
);
};
const walkFiles = (root: string): string[] => {
if (!existsSync(root)) {
return [];
}
const paths: string[] = [];
for (const entry of readdirSync(root)) {
const fullPath = path.join(root, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
paths.push(...walkFiles(fullPath));
continue;
}
paths.push(fullPath);
}
return paths;
};
const copyArtifact = (source: string, outputName: string): string => {
const destination = path.join(PACKAGE_ROOT, outputName);
rmSync(destination, { force: true, recursive: true });
cpSync(source, destination, { recursive: true });
return destination;
};
const signUnsignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --force --deep --sign - ${appPath}`;
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const verifySignedMacApp = async (appPath: string): Promise<void> => {
await $`codesign --verify --deep --strict --verbose=2 ${appPath}`;
await $`spctl --assess --type execute --verbose ${appPath}`;
await $`xattr -cr ${appPath}`;
};
const collectMacArtifacts = async (
version: string,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const appPath = path.join(BUNDLE_ROOT, "macos", `${APP_NAME}.app`);
if (!existsSync(appPath)) {
throw new Error(`macOS app bundle was not created at ${appPath}`);
}
if (allowUnsignedMac && !macDistributionCredentialsConfigured()) {
console.warn(
"creating a local-only ad-hoc signed macOS package; this is not suitable for quarantined downloads.",
);
await signUnsignedMacApp(appPath);
} else {
await verifySignedMacApp(appPath);
}
const arch = process.arch === "arm64" ? "arm64" : "x64";
const suffix =
allowUnsignedMac && !macDistributionCredentialsConfigured()
? "-local-unsigned"
: "";
const zipName = `${sanitizeName(APP_NAME)}-${version}-macos-${arch}${suffix}.zip`;
const zipPath = path.join(PACKAGE_ROOT, zipName);
rmSync(zipPath, { force: true });
await $`ditto -c -k --keepParent ${appPath} ${zipPath}`;
const artifacts = [zipPath];
if (!suffix) {
for (const dmgPath of walkFiles(path.join(BUNDLE_ROOT, "dmg")).filter(
(file) => file.endsWith(".dmg"),
)) {
artifacts.push(copyArtifact(dmgPath, path.basename(dmgPath)));
}
}
return artifacts;
};
const collectWindowsArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter((file) => file.endsWith(".msi") || file.endsWith(".exe"))
.map((file) => copyArtifact(file, path.basename(file)));
const collectLinuxArtifacts = (): string[] =>
walkFiles(BUNDLE_ROOT)
.filter(
(file) =>
file.endsWith(".AppImage") ||
file.endsWith(".deb") ||
file.endsWith(".rpm"),
)
.map((file) => copyArtifact(file, path.basename(file)));
const collectArtifacts = async (
platform: DesktopPlatform,
allowUnsignedMac: boolean,
): Promise<string[]> => {
const version = await packageVersion();
rmSync(PACKAGE_ROOT, { force: true, recursive: true });
mkdirSync(PACKAGE_ROOT, { recursive: true });
if (platform === "mac") {
return collectMacArtifacts(version, allowUnsignedMac);
}
if (platform === "windows") {
return collectWindowsArtifacts();
}
return collectLinuxArtifacts();
};
const main = async () => {
validateArgs();
const platform = resolveRequestedPlatform();
const allowUnsignedMac =
hasArg("--allow-unsigned-mac") || process.env.ALLOW_UNSIGNED_MAC === "1";
const skipBuild = hasArg("--skip-build");
assertCanBuildPlatform(platform);
if (platform === "mac") {
assertMacDistributionReady(allowUnsignedMac);
}
if (!skipBuild) {
await $`bun run build:binary`;
}
const artifacts = await collectArtifacts(platform, allowUnsignedMac);
if (artifacts.length === 0) {
throw new Error(
`no ${platform} desktop artifacts were found under ${BUNDLE_ROOT}`,
);
}
console.log(`Packaged ${platform} desktop artifacts:`);
for (const artifact of artifacts) {
console.log(`- ${path.relative(APP_ROOT, artifact)}`);
}
};
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -1,259 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
prewarmWorkspaceMetadata,
rewriteDesktopTeamPrompt,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import type { SidecarContext } from "./types";
describe("rewriteDesktopTeamPrompt", () => {
it("rewrites /team for the core runtime", () => {
expect(rewriteDesktopTeamPrompt("/team inspect the app", new Set())).toBe(
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
);
});
it("rejects /team when the Teams tool is disabled", () => {
expect(() =>
rewriteDesktopTeamPrompt("/team inspect the app", new Set(["teams"])),
).toThrow("Agent teams are disabled");
});
});
describe("buildSessionConnectionUpdate", () => {
it("does not clear reasoning settings when config omits reasoning fields", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
});
expect(Object.hasOwn(update, "thinking")).toBe(false);
expect(Object.hasOwn(update, "reasoningEffort")).toBe(false);
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
it("clears reasoning settings when thinking is explicitly disabled", () => {
expect(
buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: false,
}),
).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: false,
reasoningEffort: null,
thinkingBudgetTokens: null,
});
});
it("updates explicit reasoning settings without clearing omitted settings", () => {
const update = buildSessionConnectionUpdate({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
reasoningEffort: "high",
});
expect(update).toEqual({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
});
expect(Object.hasOwn(update, "thinkingBudgetTokens")).toBe(false);
});
});
describe("shouldUpdateSessionConnection", () => {
it("skips the redundant connection update on the first send", () => {
const config = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
expect(shouldUpdateSessionConnection(config, { ...config })).toBe(false);
});
it("updates the connection when the selected reasoning level changes", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "low",
};
expect(
shouldUpdateSessionConnection(current, {
...current,
reasoningEffort: "high",
}),
).toBe(true);
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
};
function createContext(options?: {
attachedViaHub?: boolean;
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const sessionId = "session-connection-test";
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: options?.config ?? baseConfig,
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
attachedViaHub: options?.attachedViaHub ?? false,
},
],
]),
sessionManager: { send, updateSessionConnection },
} as unknown as SidecarContext;
return { ctx, send, sessionId, updateSessionConnection };
}
it("skips an identical update for a locally-created session", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledTimes(1);
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
expect(updateSessionConnection.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { ...baseConfig },
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
});
});
describe("workspace metadata prewarming", () => {
it("reuses one in-flight scan and consumes it only once", async () => {
let resolveFirst: ((value: string) => void) | undefined;
const firstResult = new Promise<string>((resolve) => {
resolveFirst = resolve;
});
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockImplementationOnce(async () => await firstResult)
.mockResolvedValueOnce("fresh metadata");
const cwd = "/tmp/cline-desktop-prewarm-reuse";
prewarmWorkspaceMetadata(cwd, load);
const consumed = consumeWorkspaceMetadata(cwd, load);
expect(load).toHaveBeenCalledTimes(1);
resolveFirst?.("prewarmed metadata");
await expect(consumed).resolves.toBe("prewarmed metadata");
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"fresh metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("evicts failed scans so the next session can retry", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockRejectedValueOnce(new Error("git unavailable"))
.mockResolvedValueOnce("recovered metadata");
const cwd = "/tmp/cline-desktop-prewarm-retry";
prewarmWorkspaceMetadata(cwd, load);
await expect(consumeWorkspaceMetadata(cwd, load)).rejects.toThrow(
"git unavailable",
);
await expect(consumeWorkspaceMetadata(cwd, load)).resolves.toBe(
"recovered metadata",
);
expect(load).toHaveBeenCalledTimes(2);
});
it("keeps different workspaces in separate single-flight entries", () => {
const load = vi.fn(async (cwd: string) => `metadata for ${cwd}`);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-a", load);
prewarmWorkspaceMetadata("/tmp/cline-desktop-prewarm-b", load);
expect(load).toHaveBeenCalledTimes(2);
});
it("refreshes a prewarm that is older than the startup window", async () => {
const load = vi
.fn<(cwd: string) => Promise<string>>()
.mockResolvedValueOnce("startup metadata")
.mockResolvedValueOnce("current metadata");
const cwd = "/tmp/cline-desktop-prewarm-expired";
prewarmWorkspaceMetadata(cwd, load, () => 0);
await expect(
consumeWorkspaceMetadata(
cwd,
load,
() => WORKSPACE_METADATA_PREWARM_TTL_MS + 1,
),
).resolves.toBe("current metadata");
expect(load).toHaveBeenCalledTimes(2);
});
});
+15 -202
View File
@@ -1,18 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import { basename, join } from "node:path";
import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
readGlobalSettings,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
import { buildClineSystemPrompt } from "@cline/shared";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -23,98 +20,6 @@ import type {
SidecarContext,
} from "./types";
type SessionConnectionUpdate = Parameters<
ClineCore["updateSessionConnection"]
>[1];
type WorkspaceMetadataLoader = (cwd: string) => Promise<string>;
type WorkspaceMetadataCacheEntry = {
createdAt: number;
promise: Promise<string>;
};
export const WORKSPACE_METADATA_PREWARM_TTL_MS = 60_000;
const workspaceMetadataPromises = new Map<
string,
WorkspaceMetadataCacheEntry
>();
export function rewriteDesktopTeamPrompt(
prompt: string,
disabledTools: ReadonlySet<string> = new Set(
readGlobalSettings().disabledTools ?? [],
),
): string {
const match = /^\/team\b([\s\S]*)$/i.exec(prompt.trim());
if (!match) return prompt;
const task = match[1]?.trim();
if (!task) {
throw new Error(
"Usage: /team <task description>. Starts a team of agents for the given task.",
);
}
if (disabledTools.has("teams")) {
throw new Error(
"Agent teams are disabled. Enable the Teams tool in Customizations → Tools.",
);
}
return formatUserCommandBlock(
`spawn a team of agents for the following task: ${task}`,
"team",
);
}
function getWorkspaceMetadataPromise(
cwd: string,
load: WorkspaceMetadataLoader,
now: () => number,
): { key: string; promise: Promise<string> } {
const key = resolve(cwd);
const existing = workspaceMetadataPromises.get(key);
const createdAt = now();
if (
existing &&
createdAt - existing.createdAt <= WORKSPACE_METADATA_PREWARM_TTL_MS
) {
return { key, promise: existing.promise };
}
const promise = load(key);
workspaceMetadataPromises.set(key, { createdAt, promise });
void promise.catch(() => {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
});
return { key, promise };
}
export function prewarmWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): void {
void getWorkspaceMetadataPromise(cwd, load, now).promise.catch(() => {});
}
export async function consumeWorkspaceMetadata(
cwd: string,
load: WorkspaceMetadataLoader = buildWorkspaceMetadata,
now: () => number = Date.now,
): Promise<string> {
const { key, promise } = getWorkspaceMetadataPromise(cwd, load, now);
try {
return await promise;
} finally {
if (workspaceMetadataPromises.get(key)?.promise === promise) {
workspaceMetadataPromises.delete(key);
}
}
}
export function refreshWorkspaceMetadata(cwd: string): void {
workspaceMetadataPromises.delete(resolve(cwd));
prewarmWorkspaceMetadata(cwd);
}
// ---------------------------------------------------------------------------
// Session data helpers
// ---------------------------------------------------------------------------
@@ -198,40 +103,7 @@ function isoTimestampToMs(
return Number.isFinite(parsed) ? parsed : undefined;
}
function readReasoningEffort(
value: unknown,
): "low" | "medium" | "high" | "xhigh" | undefined {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
) {
return value;
}
return undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.trunc(value);
}
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
thinking === false
? undefined
: readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens =
thinking === false
? undefined
: readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return {
sessionId: config.sessionId ?? config.session_id,
providerId: config.provider ?? config.providerId ?? "",
@@ -243,9 +115,16 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
...(thinking !== undefined ? { thinking } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
enableSpawnAgent:
config.enableSpawn ??
config.enableSpawnAgent ??
config.enable_spawn ??
false,
enableAgentTeams:
config.enableTeams ??
config.enableAgentTeams ??
config.enable_teams ??
false,
teamName: config.teamName ?? config.team_name,
missionLogIntervalSteps:
config.missionStepInterval ?? config.missionLogIntervalSteps,
@@ -257,58 +136,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
};
}
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
// into typed fields; the thinking/reasoning transition rules live in the
// shared @cline/core builder.
const providerId = String(config.provider ?? config.providerId ?? "").trim();
const modelId = String(config.model ?? config.modelId ?? "").trim();
const rawApiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
const baseUrl =
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return buildConnectionUpdate({
...(providerId ? { providerId } : {}),
...(modelId ? { modelId } : {}),
...(rawApiKey ? { apiKey: rawApiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...(config.headers && typeof config.headers === "object"
? { headers: config.headers as Record<string, string> }
: {}),
...(config.providerConfig && typeof config.providerConfig === "object"
? {
providerConfig:
config.providerConfig as SessionConnectionUpdate["providerConfig"],
}
: {}),
...(typeof config.thinking === "boolean"
? { thinking: config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
});
}
export function shouldUpdateSessionConnection(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
return !isDeepStrictEqual(
buildSessionConnectionUpdate(currentConfig),
buildSessionConnectionUpdate(nextConfig),
);
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -322,7 +149,7 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
: config.mode === "plan"
? "plan"
: "act";
const metadata = await consumeWorkspaceMetadata(cwd);
const metadata = await buildWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
? config.rules
@@ -534,22 +361,8 @@ async function handleSend(
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const runtimePrompt = rewriteDesktopTeamPrompt(prompt);
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, request.config)
) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
}
if (session) {
session.config = { ...session.config, ...request.config };
}
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
@@ -568,7 +381,7 @@ async function handleSend(
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
await manager.send({
sessionId,
prompt: runtimePrompt,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
});
@@ -592,7 +405,7 @@ async function handleSend(
);
const result = await manager.send({
sessionId,
prompt: runtimePrompt,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
+8 -99
View File
@@ -1,11 +1,5 @@
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type {
ClineAccountActionRequest,
@@ -31,7 +25,6 @@ import {
listLocalProviders,
listPluginTools,
loginAndSaveLocalProviderOAuthCredentials,
markLocalProviderEnabled,
normalizeOAuthProvider,
ProviderSettingsManager,
readGlobalSettings,
@@ -42,27 +35,13 @@ import {
SqliteSessionStore,
saveLocalProviderSettings,
sendHubCommand,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { broadcastEvent, resolveSidecarAskQuestion } from "./context";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
findArtifactUnderDir,
readSessionManifest,
@@ -559,7 +538,7 @@ async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSyncStrippingUtf8Bom(filePath);
const raw = readFileSync(filePath, "utf8");
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -638,8 +617,6 @@ async function listUserInstructionConfigs(
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
const builtinToolCatalog = getCoreBuiltinToolCatalog({
enableSpawnAgent: true,
enableAgentTeams: true,
disabledToolIds: disabledTools,
});
@@ -968,7 +945,7 @@ export async function handleCommand(
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
await ensureCustomProvidersLoaded(manager);
return await listLocalProviders(manager, { isClinePassEnabled: true });
return await listLocalProviders(manager);
}
if (command === "list_provider_models") {
const manager = new ProviderSettingsManager();
@@ -1048,45 +1025,12 @@ export async function handleCommand(
spawned.unref();
},
);
if (saved.provider !== providerId) {
markLocalProviderEnabled(manager, providerId, { tokenSource: "oauth" });
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
}
// ── Global settings ────────────────────────────────────────────────
if (command === "get_global_settings") {
return readGlobalSettings();
}
if (command === "set_telemetry_opt_out") {
if (typeof args?.telemetry_opt_out !== "boolean") {
throw new Error("telemetry_opt_out must be a boolean");
}
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "set_auto_update_enabled") {
if (typeof args?.auto_update_enabled !== "boolean") {
throw new Error("auto_update_enabled must be a boolean");
}
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
if (command === "start_connector_channel") {
return await startConnectorChannel(ctx.workspaceRoot, args);
}
if (command === "stop_connector_channel") {
return await stopConnectorChannel(ctx.workspaceRoot, args);
}
// ── MCP server management ─────────────────────────────────────────
if (command === "list_mcp_servers") {
return readMcpServersResponse();
@@ -1172,13 +1116,10 @@ export async function handleCommand(
// ── Git operations ─────────────────────────────────────────────────
if (command === "get_git_branch") {
const cwd =
typeof args?.cwd === "string" && args.cwd.trim()
? args.cwd.trim()
: ctx.workspaceRoot;
const branches = listGitBranches(ctx, cwd);
const { prewarmWorkspaceMetadata } = await import("./chat-session");
prewarmWorkspaceMetadata(cwd);
const branches = listGitBranches(
ctx,
typeof args?.cwd === "string" ? args.cwd : undefined,
);
return { branch: branches.current };
}
if (command === "list_git_branches") {
@@ -1191,14 +1132,11 @@ export async function handleCommand(
const cwd = typeof args?.cwd === "string" ? args.cwd : undefined;
const branch = String(args?.branch ?? "").trim();
if (!branch) throw new Error("branch is required");
const targetCwd = cwd?.trim() || ctx.workspaceRoot;
execFileSync("git", ["checkout", branch], {
cwd: targetCwd,
cwd: cwd?.trim() || ctx.workspaceRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const { refreshWorkspaceMetadata } = await import("./chat-session");
refreshWorkspaceMetadata(targetCwd);
return { branch };
}
@@ -1218,26 +1156,6 @@ export async function handleCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(ctx.workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(ctx.workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, {
workspaceRoot: ctx.workspaceRoot,
});
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) {
@@ -1267,15 +1185,6 @@ export async function handleCommand(
}
// ── Native OS commands ────────────────────────────────────────────
if (command === "validate_workspace_directory") {
const workspacePath = String(args?.path ?? "").trim();
if (!workspacePath) return { valid: false };
try {
return { valid: statSync(workspacePath).isDirectory() };
} catch {
return { valid: false };
}
}
if (command === "pick_workspace_directory") {
return pickWorkspaceDirectory();
}
@@ -1,307 +0,0 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
import type { JsonRecord } from "./types";
type ConnectorField = {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: {
flag: string;
equals?: string;
notEquals?: string;
};
};
type ConnectorSecurityField = {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
};
type WebviewConnectorChannel = {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorField[];
security?: {
prompt: string;
fields: ConnectorSecurityField[];
};
};
type WebviewConnectorChannelsResponse = {
available: WebviewConnectorChannel[];
active: ReturnType<typeof listActiveConnectors>;
};
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
workspaceRoot: string,
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath =
options.cliPath ?? normalize(join(workspaceRoot, "apps/cli/src/index.ts"));
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
initialValue: field.initialValue,
options: field.options,
includeWhen: field.includeWhen,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
return { available, active: listActiveConnectors() };
}
async function runCliConnectCommand(
workspaceRoot: string,
args: string[],
): Promise<{
code: number;
stdout: string;
stderr: string;
}> {
const { launcher, childArgs } = buildCliConnectCommand(workspaceRoot, args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const code = await new Promise<number>((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode) => resolve(exitCode ?? 0));
});
return { code, stdout, stderr };
}
async function waitForConnectorState(
predicate: () => boolean,
timeoutMs = 5_000,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
}
return cliArgs;
}
export async function startConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
@@ -4,9 +4,6 @@ import type { SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -17,18 +14,7 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
subscribe = subscribeMock;
dispose = vi.fn();
@@ -53,20 +39,8 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -83,15 +57,6 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -102,20 +67,11 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
}),
);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
@@ -192,8 +148,6 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -5,13 +5,10 @@ import { dirname } from "node:path";
import {
type AgentToolContext,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
@@ -389,7 +386,6 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
unsubscribeSessionEvents: null,
};
@@ -434,12 +430,6 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -692,19 +682,10 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -722,7 +703,6 @@ export async function initializeSessionManager(
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
@@ -736,6 +716,5 @@ export async function initializeSessionManager(
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
+3 -7
View File
@@ -1,4 +1,3 @@
import { prewarmWorkspaceMetadata } from "./chat-session";
import {
createSidecarContext,
disposeSidecarContext,
@@ -6,7 +5,7 @@ import {
} from "./context";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -35,7 +34,6 @@ async function main() {
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
const ctx = createSidecarContext(workspaceRoot);
prewarmWorkspaceMetadata(workspaceRoot);
await initializeSessionManager(ctx);
let shuttingDown = false;
@@ -61,10 +59,8 @@ async function main() {
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
const endpoint = `http://127.0.0.1:${port}`;
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
process.stdout.write(
`${JSON.stringify({
type: "ready",
@@ -1,998 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir as osHomedir, platform } from "node:os";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
type MarketplaceInstallInput = {
id: string;
type: MarketplacePrimitiveType;
name?: string;
install: {
args?: string[];
env?: MarketplaceEnvVar[];
command?: string;
notes?: string;
};
};
type MarketplaceInstallResult = {
id: string;
type: LocalPrimitiveType;
status: "installed" | "uninstalled";
message: string;
details?: JsonRecord;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
type SpawnCommand = (
command: string,
args: string[],
options?: SpawnOptions,
) => Promise<SpawnResult>;
type CatalogFetch = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
type CatalogLoader = () => Promise<unknown>;
const MAX_OUTPUT_CHARS = 12_000;
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
const MARKETPLACE_CATALOG_URL =
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
const SECRET_KEY_VALUE_PATTERN =
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
const SECRET_BEARER_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
const SECRET_AUTHORIZATION_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
): Promise<unknown> {
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
);
}
return response.json();
}
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function readInstallInput(
args?: Record<string, unknown>,
): MarketplaceInstallInput {
const entry = readInstallRecord(args);
const install =
entry.install && typeof entry.install === "object"
? (entry.install as Record<string, unknown>)
: {};
const installArgs = toStringArray(install.args);
if (installArgs.length === 0) {
throw new Error("marketplace install args are required");
}
const env = Array.isArray(install.env)
? install.env
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null)
: undefined;
return {
id: entry.id.trim(),
type: entry.type,
name: typeof entry.name === "string" ? entry.name : undefined,
install: {
args: installArgs,
command:
typeof install.command === "string" ? install.command : undefined,
env,
notes: typeof install.notes === "string" ? install.notes : undefined,
},
};
}
function readInstallRecord(
args?: Record<string, unknown>,
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
const entry =
args?.entry && typeof args.entry === "object"
? (args.entry as Record<string, unknown>)
: (args ?? {});
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
throw new Error("marketplace entry id is required");
}
if (!isPrimitiveType(entry.type)) {
throw new Error("marketplace entry type must be mcp, skill, or plugin");
}
return entry as Record<string, unknown> & {
id: string;
type: MarketplacePrimitiveType;
};
}
function readInstallRequest(args?: Record<string, unknown>) {
const entry = readInstallRecord(args);
return {
id: entry.id.trim(),
type: entry.type,
};
}
function readLocalUninstallInput(args?: Record<string, unknown>): {
id: string;
type: LocalPrimitiveType;
name?: string;
path?: string;
} {
const type = typeof args?.type === "string" ? args.type.trim() : "";
if (
type !== "mcp" &&
type !== "skill" &&
type !== "workflow" &&
type !== "plugin"
) {
throw new Error(
"local uninstall type must be mcp, skill, workflow, or plugin",
);
}
const id =
typeof args?.id === "string" && args.id.trim().length > 0
? args.id.trim()
: typeof args?.name === "string" && args.name.trim().length > 0
? args.name.trim()
: typeof args?.path === "string" && args.path.trim().length > 0
? args.path.trim()
: "";
if (!id) {
throw new Error("local uninstall id, name, or path is required");
}
return {
id,
type,
name: typeof args?.name === "string" ? args.name.trim() : undefined,
path: typeof args?.path === "string" ? args.path.trim() : undefined,
};
}
function readInstallInputList(
args?: Record<string, unknown>,
): MarketplaceInstallInput[] {
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
return rawEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
const catalogEntries =
catalog && typeof catalog === "object"
? (catalog as Record<string, unknown>).entries
: undefined;
if (!Array.isArray(catalogEntries)) {
throw new Error("marketplace catalog entries are required");
}
return catalogEntries
.map((entry) => {
try {
return readInstallInput({ entry });
} catch {
return null;
}
})
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
}
function marketplaceEntryKey(
entry: Pick<MarketplaceInstallInput, "id" | "type">,
) {
return `${entry.type}:${entry.id}`;
}
function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
);
});
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
}
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
new Promise<SpawnResult>((resolve, reject) => {
let settled = false;
let timedOut = false;
const child = spawn(command, args, {
...options,
env: options.env ?? process.env,
shell: options.shell ?? platform() === "win32",
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
const forceKillTimeout = setTimeout(() => {
if (!settled) {
child.kill("SIGKILL");
}
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
const timeout = setTimeout(() => {
timedOut = true;
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
child.kill("SIGTERM");
}, INSTALL_COMMAND_TIMEOUT_MS);
forceKillTimeout.unref?.();
timeout.unref?.();
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
}
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
}
});
child.once("error", (error) => {
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
reject(error);
});
child.once("close", (code, signal) => {
settled = true;
clearTimeout(timeout);
clearTimeout(forceKillTimeout);
const result = {
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
stdout,
stderr,
};
resolve(result);
});
});
function normalizeTransport(value: string | undefined): string {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertUrl(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid MCP server URL: ${value}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Invalid MCP server URL: ${value}`);
}
}
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
const [rawName, ...rest] = args;
const name = rawName?.trim();
if (!name) {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const headers: Record<string, string> = {};
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
const arg = rest[index];
if (parsingMarketplaceOptions && arg === "--") {
targetArgs.push(...rest.slice(index + 1));
break;
}
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
const next = rest[index + 1]?.trim();
if (!next) throw new Error("--transport requires a value");
transportType = normalizeTransport(next);
index++;
continue;
}
const shouldParseHeader =
parsingMarketplaceOptions ||
normalizeTransport(transportType) !== "stdio";
if (
shouldParseHeader &&
(arg === "--header" || arg?.startsWith("--header="))
) {
const rawHeader =
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
if (!rawHeader) throw new Error("--header requires a value");
const separatorIndex = rawHeader.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
const headerName = rawHeader.slice(0, separatorIndex).trim();
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
if (!headerName || !headerValue) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
headers[headerName] = headerValue;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
if (Object.keys(headers).length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
}
return {
name,
transportType,
command,
args: commandArgs.length > 0 ? commandArgs : undefined,
disabled: false,
};
}
if (targetArgs.length !== 1) {
throw new Error("Remote MCP install requires exactly one URL");
}
const url = targetArgs[0]?.trim() ?? "";
assertUrl(url);
return {
name,
transportType,
url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
disabled: false,
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function resolveUserInstructionRemovalTarget(input: {
type: "skill" | "workflow";
path: string;
workspaceRoot?: string;
}): string {
const filePath = resolve(input.path);
const searchPaths =
input.type === "skill"
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
const containingRoot = searchPaths.find((root) =>
isInsidePath(filePath, root),
);
if (!containingRoot) {
throw new Error(
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
);
}
const stats = statSync(filePath, { throwIfNoEntry: false });
if (!stats?.isFile()) {
throw new Error(`${input.type} file does not exist: ${filePath}`);
}
if (input.type === "workflow") {
return filePath;
}
const skillDir = dirname(filePath);
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
}
export async function uninstallLocalPrimitive(
args?: Record<string, unknown>,
options: { workspaceRoot?: string } = {},
): Promise<MarketplaceInstallResult> {
const input = readLocalUninstallInput(args);
if (input.type === "mcp") {
const name = input.name ?? input.id;
const response = deleteMcpServer(name);
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
details: { mcp: response },
};
}
if (input.type === "plugin") {
const result = await uninstallLocalPlugin({
name: input.path ? undefined : (input.name ?? input.id),
path: input.path,
workspaceRoot: options.workspaceRoot,
});
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
details: result as unknown as JsonRecord,
};
}
if (input.type === "skill" || input.type === "workflow") {
if (!input.path) {
throw new Error(`${input.type} uninstall requires a path.`);
}
const target = resolveUserInstructionRemovalTarget({
type: input.type,
path: input.path,
workspaceRoot: options.workspaceRoot,
});
const stats = statSync(target, { throwIfNoEntry: false });
if (!stats) {
throw new Error(`${input.type} target does not exist: ${target}`);
}
rmSync(target, { recursive: stats.isDirectory(), force: true });
return {
id: input.id,
type: input.type,
status: "uninstalled",
message: `Uninstalled ${input.name ?? basename(target)}.`,
details: { path: target },
};
}
throw new Error(`Unsupported local uninstall type: ${input.type}`);
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
function sanitizeSkillSegment(value: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z0-9._]+/g, "-")
.replace(/^[.-]+|[.-]+$/g, "")
.slice(0, 255);
return sanitized || "skill";
}
function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function getOfficialPluginInstallPath(source: string): string | undefined {
const slug = source.trim();
if (!isOfficialPluginSlug(slug)) return undefined;
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
return join(
resolveClineDir(),
"plugins",
"_installed",
"official",
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
);
}
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "plugin") return false;
const [source] = entry.install.args ?? [];
if (!source) return false;
const installPath = getOfficialPluginInstallPath(source);
return Boolean(installPath && existsSync(installPath));
}
function resolveHomeDir(): string {
return (
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
);
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
const candidates = new Set<string>();
const addCandidate = (value: string | undefined) => {
const normalized = sanitizeSkillSegment(value ?? "");
if (normalized && normalized !== "skill") {
candidates.add(normalized);
}
};
addCandidate(entry.id);
addCandidate(entry.name);
const installArgs = entry.install.args ?? [];
for (let index = 0; index < installArgs.length; index++) {
const arg = installArgs[index];
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
addCandidate(installArgs[index + 1]);
index++;
continue;
}
const skillFilter = arg.split("@").at(1);
if (skillFilter) {
addCandidate(skillFilter);
}
}
return [...candidates];
}
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
skillsDir,
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
);
writeFileSync(probePath, "", { flag: "wx" });
unlinkSync(probePath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
);
}
}
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
return findInstalledGlobalSkillName(entry) !== undefined;
}
function findInstalledGlobalSkillName(
entry: MarketplaceInstallInput,
): string | undefined {
if (entry.type !== "skill") return undefined;
const candidates = getSkillInstallCandidates(entry);
return candidates.find((candidate) =>
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
);
}
function hasMatchingInventoryItem(
items: unknown,
entry: MarketplaceInstallInput,
): boolean {
if (!Array.isArray(items)) return false;
const candidates = new Set([
normalizeMatchValue(entry.id),
normalizeMatchValue(entry.name),
...(entry.install.args ?? []).map(normalizeMatchValue),
]);
candidates.delete("");
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
typeof record.path === "string" ? record.path : undefined,
]
.map(normalizeMatchValue)
.filter(Boolean);
return values.some((value) => candidates.has(value));
});
}
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
if (entry.type !== "mcp") return false;
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = readMcpServersResponse();
const servers = Array.isArray(response.servers) ? response.servers : [];
return servers.some((server) => {
if (!server || typeof server !== "object") return false;
const record = server as JsonRecord;
return record.name === input.name;
});
}
function isMarketplaceEntryInstalled(
entry: MarketplaceInstallInput,
inventory?: JsonRecord,
): boolean {
try {
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
if (entry.type === "plugin") {
return (
isOfficialPluginInstalled(entry) ||
hasMatchingInventoryItem(inventory?.plugins, entry)
);
}
if (entry.type === "skill") {
return isGlobalSkillInstalled(entry);
}
return false;
} catch {
return false;
}
}
function commandOutput(result: SpawnResult): string | undefined {
const output = redactOutput(
[result.stdout, result.stderr].filter(Boolean).join("\n"),
);
return output.trim().length > 0 ? output.trim() : undefined;
}
async function installSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
if (isGlobalSkillInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"add",
...(entry.install.args ?? []),
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (/\bFailed to install\b/i.test(output ?? "")) {
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
}
if (!isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
throw new Error(
"Plugin marketplace installs currently support exactly one source argument.",
);
}
if (isOfficialPluginInstalled(entry)) {
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function uninstallMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
}
export async function installMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return installMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export async function uninstallMarketplaceEntryFromCatalog(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
const requested = readInstallRequest(args);
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
const entry = readCatalogEntries(catalog).find(
(candidate) =>
candidate.id === requested.id && candidate.type === requested.type,
);
if (!entry) {
throw new Error(
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
);
}
return uninstallMarketplaceEntry(
{ entry },
{ spawnCommand: options.spawnCommand },
);
}
export function listMarketplaceInstalledEntries(
args?: Record<string, unknown>,
inventory?: JsonRecord,
): MarketplaceInstallStatusResult {
const entries = readInstallInputList(args);
const installedKeys = entries
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
.map(marketplaceEntryKey);
return { installedKeys };
}
export async function installMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return installMarketplaceEntryFromCatalog(args, options);
}
export async function uninstallMarketplaceEntryForDesktopCommand(
args?: Record<string, unknown>,
options: {
spawnCommand?: SpawnCommand;
loadCatalog?: CatalogLoader;
} = {},
): Promise<MarketplaceInstallResult> {
return uninstallMarketplaceEntryFromCatalog(args, options);
}
-154
View File
@@ -1,154 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { updateMcpSettingsFileSync } from "@cline/core";
import { resolveMcpSettingsPath } from "@cline/shared/storage";
import type { JsonRecord } from "./types";
export function readMcpServersResponse(): JsonRecord {
const settingsPath = resolveMcpSettingsPath();
if (!existsSync(settingsPath)) {
return { settingsPath, hasSettingsFile: false, servers: [] };
}
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
const servers = parsed.mcpServers as JsonRecord | undefined;
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
const record = body as JsonRecord;
const transport =
record.transport && typeof record.transport === "object"
? (record.transport as JsonRecord)
: undefined;
const transportType = String(
transport?.type ?? record.transportType ?? record.type ?? "stdio",
).trim();
return {
name,
transportType,
disabled: record.disabled === true,
command:
typeof transport?.command === "string"
? transport.command
: typeof record.command === "string"
? record.command
: undefined,
args: Array.isArray(transport?.args)
? transport.args
: Array.isArray(record.args)
? record.args
: undefined,
cwd:
typeof transport?.cwd === "string"
? transport.cwd
: typeof record.cwd === "string"
? record.cwd
: undefined,
env:
transport?.env && typeof transport.env === "object"
? transport.env
: record.env && typeof record.env === "object"
? record.env
: undefined,
url:
typeof transport?.url === "string"
? transport.url
: typeof record.url === "string"
? record.url
: undefined,
headers:
transport?.headers && typeof transport.headers === "object"
? transport.headers
: record.headers && typeof record.headers === "object"
? record.headers
: undefined,
metadata: record.metadata,
};
});
return { settingsPath, hasSettingsFile: true, servers: entries };
}
export function writeMcpServersMap(servers: JsonRecord): void {
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
settings.mcpServers = servers;
});
}
export function ensureMcpSettingsFile(): string {
const path = resolveMcpSettingsPath();
if (!existsSync(path)) {
writeMcpServersMap({});
}
return path;
}
export function setMcpServerDisabled(
name: string,
disabled: boolean,
): JsonRecord {
// Hold the cross-process lock across read-modify-write so a concurrent writer
// (the extension, the CLI) cannot clobber this change.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
const current = servers[name];
if (!current || typeof current !== "object") {
throw new Error(`unknown MCP server: ${name}`);
}
servers[name] = { ...(current as JsonRecord), disabled };
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function upsertMcpServer(input: JsonRecord): JsonRecord {
const name = String(input.name ?? "").trim();
if (!name) throw new Error("server name is required");
const previousName = String(
input.previousName ?? input.previous_name ?? "",
).trim();
const transportType = String(
input.transportType ?? input.transport_type ?? "",
).trim();
const next: JsonRecord =
transportType === "stdio"
? {
transport: {
type: "stdio",
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
},
disabled: input.disabled === true,
}
: {
transport: {
type: transportType === "sse" ? "sse" : "streamableHttp",
url: input.url,
headers: input.headers,
},
disabled: input.disabled === true,
};
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot clobber this upsert.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
if (previousName && previousName !== name) {
delete servers[previousName];
}
servers[name] = next;
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
export function deleteMcpServer(name: string): JsonRecord {
if (!name) throw new Error("server name is required");
// Hold the cross-process lock across read-modify-write so a concurrent writer
// cannot resurrect the deleted server from a stale snapshot.
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
{}) as JsonRecord;
delete servers[name];
settings.mcpServers = servers;
});
return readMcpServersResponse();
}
@@ -1,84 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createFetchHandler } from "./server";
import type { SidecarContext } from "./types";
function createTestServer() {
return {
port: 3126,
upgrade: vi.fn(() => true),
};
}
function createHandler(onShutdown = vi.fn()) {
return createFetchHandler({} as SidecarContext, onShutdown);
}
describe("sidecar HTTP origin checks", () => {
it("rejects cross-origin shutdown preflight requests", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/shutdown", {
method: "OPTIONS",
headers: {
origin: "https://attacker.example",
"access-control-request-method": "POST",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(response?.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects cross-origin shutdown POST requests", async () => {
const onShutdown = vi.fn();
const server = createTestServer();
const response = await createHandler(onShutdown)(
new Request("http://127.0.0.1:3126/shutdown", {
method: "POST",
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(403);
expect(onShutdown).not.toHaveBeenCalled();
});
it("rejects cross-origin websocket upgrades", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/transport", {
headers: {
origin: "https://attacker.example",
},
}),
server,
);
expect(response?.status).toBe(404);
expect(server.upgrade).not.toHaveBeenCalled();
});
it("allows desktop webview origins in preflight responses", async () => {
const server = createTestServer();
const response = await createHandler()(
new Request("http://127.0.0.1:3126/api/marketplace/catalog", {
method: "OPTIONS",
headers: {
origin: "tauri://localhost",
"access-control-request-method": "GET",
},
}),
server,
);
expect(response?.status).toBe(204);
expect(response?.headers.get("access-control-allow-origin")).toBe(
"tauri://localhost",
);
});
});
+5 -112
View File
@@ -1,10 +1,8 @@
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import {
BunRuntime,
SIDECAR_HOST,
SIDECAR_MODE,
SIDECAR_PORT,
type SidecarContext,
@@ -16,57 +14,6 @@ type SidecarServer = {
upgrade(req: Request): boolean;
};
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
// the sidecar runs inside a container). Origin validation itself stays on.
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const TRUSTED_BROWSER_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
"http://localhost:3125",
"http://127.0.0.1:3125",
...EXTRA_TRUSTED_ORIGINS,
]);
const JSON_HEADERS = {
"content-type": "application/json",
};
function readOrigin(req: Request): string | undefined {
const origin = req.headers.get("origin")?.trim();
return origin ? origin : undefined;
}
function isTrustedRequestOrigin(req: Request): boolean {
const origin = readOrigin(req);
return !origin || TRUSTED_BROWSER_ORIGINS.has(origin);
}
function corsHeaders(req: Request): Record<string, string> {
const origin = readOrigin(req);
return {
"access-control-allow-headers": "accept, content-type",
"access-control-allow-methods": "GET, POST, OPTIONS",
...(origin && TRUSTED_BROWSER_ORIGINS.has(origin)
? {
"access-control-allow-origin": origin,
vary: "Origin",
}
: {}),
};
}
function jsonHeaders(req: Request): Record<string, string> {
return {
...JSON_HEADERS,
...corsHeaders(req),
};
}
// ---------------------------------------------------------------------------
// JSON response helper
// ---------------------------------------------------------------------------
@@ -80,29 +27,6 @@ function jsonResponse(
return JSON.stringify({ type: "response", id, ok, result, error });
}
function createJsonResponse(
req: Request,
body: unknown,
status = 200,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: jsonHeaders(req),
});
}
const EMPTY_MARKETPLACE_CATALOG = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
// ---------------------------------------------------------------------------
// Bun HTTP + WebSocket server
// ---------------------------------------------------------------------------
@@ -124,7 +48,7 @@ export function startServer(
for (const candidate of candidates) {
try {
server = BunRuntime.serve({
hostname: SIDECAR_HOST,
hostname: "127.0.0.1",
port: candidate,
fetch: createFetchHandler(ctx, onShutdown),
websocket: createWebSocketHandler(ctx),
@@ -142,20 +66,13 @@ export function startServer(
return { port: server.port };
}
export function createFetchHandler(
function createFetchHandler(
_ctx: SidecarContext,
onShutdown?: (reason?: string) => Promise<void>,
) {
return async (req: Request, server: SidecarServer) => {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
if (!isTrustedRequestOrigin(req)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
if (url.pathname === "/health") {
return new Response(
JSON.stringify({
@@ -163,39 +80,15 @@ export function createFetchHandler(
mode: SIDECAR_MODE,
pid: process.pid,
}),
{ headers: jsonHeaders(req) },
{ headers: { "content-type": "application/json" } },
);
}
if (
url.pathname === "/transport" &&
isTrustedRequestOrigin(req) &&
server.upgrade(req)
) {
if (url.pathname === "/transport" && server.upgrade(req)) {
return undefined;
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(req, await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(req, {
...EMPTY_MARKETPLACE_CATALOG,
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
});
}
}
if (url.pathname === "/shutdown" && req.method === "POST") {
if (!isTrustedRequestOrigin(req)) {
return new Response(JSON.stringify({ ok: false }), {
status: 403,
headers: jsonHeaders(req),
});
}
queueMicrotask(() => {
void onShutdown?.("code_sidecar_shutdown_endpoint")
.catch((error) => {
@@ -208,7 +101,7 @@ export function createFetchHandler(
.finally(() => process.exit(0));
});
return new Response(JSON.stringify({ ok: true }), {
headers: jsonHeaders(req),
headers: { "content-type": "application/json" },
});
}
@@ -1,7 +1,6 @@
import type {
AgentToolContext,
ClineCore,
HubServer,
NodeHubClient,
ToolApprovalResult,
} from "@cline/core";
@@ -104,7 +103,6 @@ export type SidecarContext = {
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
unsubscribeSessionEvents: (() => void) | null;
};
@@ -115,8 +113,4 @@ export type BunRuntimeApi = {
export const BunRuntime = (globalThis as { Bun?: BunRuntimeApi }).Bun;
export const SIDECAR_PORT = Number(process.env.CLINE_SIDECAR_PORT) || 3126;
// Loopback-only by default. Set CLINE_SIDECAR_HOST=0.0.0.0 to accept
// connections from outside the local host (e.g. Docker port publishing).
export const SIDECAR_HOST =
process.env.CLINE_SIDECAR_HOST?.trim() || "127.0.0.1";
export const SIDECAR_MODE = "sidecar";
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Bun/JavaScriptCore requires JIT + shared executable memory under the hardened runtime -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+15 -28
View File
@@ -179,37 +179,30 @@ fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf>
candidates.into_iter().find(|path| path.exists())
}
fn desktop_backend_binary_names() -> Vec<String> {
let extension = if cfg!(windows) { ".exe" } else { "" };
let bundled_name = format!("code-sidecar{extension}");
fn desktop_backend_binary_name() -> String {
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
if target_triple.is_empty() {
return vec![bundled_name];
return "code-sidecar".to_string();
}
vec![
bundled_name,
format!("code-sidecar-{target_triple}{extension}"),
]
let extension = if cfg!(windows) { ".exe" } else { "" };
format!("code-sidecar-{target_triple}{extension}")
}
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
if cfg!(debug_assertions) {
return None;
}
let binary_name = desktop_backend_binary_name();
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let current_exe = std::env::current_exe().ok();
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push(path);
}
for binary_name in desktop_backend_binary_names() {
candidates.push(
let candidates = [
explicit,
Some(
PathBuf::from(&context.workspace_root)
.join("apps")
.join("examples")
@@ -217,23 +210,17 @@ fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf>
.join("src-tauri")
.join("bin")
.join(&binary_name),
);
if let Some(path) = current_exe
),
current_exe
.as_ref()
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
{
candidates.push(path);
}
if let Some(path) = current_exe.as_ref().and_then(|path| {
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name))),
current_exe.as_ref().and_then(|path| {
path.parent()
.and_then(|parent| parent.parent())
.map(|parent| parent.join("Resources").join(&binary_name))
}) {
candidates.push(path);
}
}
candidates.into_iter().find(|path| path.exists())
}),
];
candidates.into_iter().flatten().find(|path| path.exists())
}
fn ensure_desktop_backend_started(
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Code",
"version": "0.0.1",
"version": "0.1.0",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -33,10 +33,6 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": "entitlements.plist",
"hardenedRuntime": true
}
]
}
}
@@ -1,13 +0,0 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./webview", import.meta.url)),
},
},
test: {
environment: "node",
},
});

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