Compare commits

..
Author SHA1 Message Date
Dominic Cooney bf6561fe7d refactor(vscode): remove dead getAvailableSlashCommands RPC and cliCompatible
The getAvailableSlashCommands gRPC handler assembled base commands +
plugin commands + workflows into a SlashCommandsResponse, tagged with
a cliCompatible flag (originally meant to distinguish VS Code-only
commands like the since-removed /explain-changes). Nothing ever called
this RPC from the webview: ChatTextArea/SlashCommandMenu compute the
autocomplete list entirely through the separate slash-commands.ts
utility, driven by the ExtensionState pushed on every state update
rather than a pulled RPC response. slash-commands.ts's local
assembly is also strictly more complete (it additionally covers MCP
prompt commands, which the RPC never included).

With no consumer, cliCompatible was dead metadata: grepping the whole
repo (webview, generated hosts, IntelliJ plugin, CLI) turned up no
reads of the field outside the handler's own tests.

Remove the RPC (and its now-unused SlashCommandInfo/SlashCommandsResponse
messages) from slash.proto, delete the handler and its tests, and drop
cliCompatible from the shared SlashCommand type and BASE_SLASH_COMMANDS.
Ran `bun run protos` to regenerate the generated ProtoBus/gRPC files
accordingly (git-ignored, not committed).
2026-07-13 19:04:57 +09:00
Dominic Cooney de5ca42df7 fix(vscode): align plugin command loading with CLI 2026-07-13 19:04:46 +09:00
Dominic Cooney 35002fef82 chore(vscode): update bun lock for plugin dependencies after rebase 2026-07-13 18:13:36 +09:00
Dominic Cooney 326a0fb24d fix(vscode): fix compile errors in plugin slash command coordinator
CI caught two real TypeScript errors that had gone unnoticed locally
(the dev environment's @cline/core build artifacts were stale, masking
them):

1. sdk-plugin-commands.ts: createContributionRegistry() was called
   without type arguments, defaulting TMessage to `unknown`. This
   doesn't match AgentExtension's setup() signature, which expects
   Message[] (per @cline/shared), causing a type mismatch. Mirror the
   CLI's equivalent call in plugin-chat-commands.ts, which explicitly
   parameterizes <Extension, AgentTool, Message[]>.

2. SdkController.ts: emitSessionEvents(messages, event) requires two
   arguments, but the plugin-command reply path only passed one. Add
   the missing status event, matching the pattern used elsewhere in
   this file (e.g. the provider-failure error path) and in
   sdk-followup-coordinator.ts.
2026-07-13 18:13:36 +09:00
Dominic Cooney 349f596e9b fix(vscode): thread plugin slash commands into autocomplete menu and navigation
ChatTextArea/SlashCommandMenu destructure pluginSlashCommands from
ExtensionState and pass it to validateSlashCommand() for input
highlighting, but never pass it to getMatchingSlashCommands() for
arrow-key navigation, Enter/Tab selection, or the rendered
SlashCommandMenu itself. Plugin-registered commands (e.g. /goal) are
discovered correctly on the backend and shipped to the webview, but
never appear in the actual autocomplete dropdown.

Thread pluginSlashCommands through all three remaining call sites and
add it to the relevant useCallback/useLayoutEffect dependency arrays
so the menu updates once plugin discovery resolves asynchronously
after mount.
2026-07-13 18:13:36 +09:00
Dominic Cooney 46eaa42e2c feat(vscode): surface plugin commands in slash command autocomplete (CLINE-2584) 2026-07-13 18:13:36 +09:00
Dominic Cooney d32a89d222 fix(vscode): bundle plugin sandbox bootstrap and surface plugin commands (CLINE-2584) 2026-07-13 18:13:36 +09:00
813 changed files with 40271 additions and 161170 deletions
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
-1
View File
@@ -1 +0,0 @@
../../.cline/skills/publish-desktop
-127
View File
@@ -1,127 +0,0 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
## Release contract
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
-158
View File
@@ -1,158 +0,0 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -1,4 +0,0 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+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
-310
View File
@@ -1,310 +0,0 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
-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:
@@ -1,60 +0,0 @@
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
# and GitHub offers no per-behavior control over an installed App, so the ad
# cannot be disabled at the source. This deletes those promo comments as they
# appear. Genuine agent output comments (work results, reviews) don't match the
# promo pattern and are left alone.
#
# No checkout, API-calls-only — comment text is only ever handled as data inside
# the script, never interpolated into the workflow definition.
name: repo-delete-agent-promo-comments
on:
issue_comment:
types: [created]
jobs:
delete:
runs-on: ubuntu-latest
timeout-minutes: 2
# Prefilter so a runner only spins up for bot comments that look like the
# ad; the script re-verifies before deleting.
if: >-
github.event.issue.pull_request &&
endsWith(github.event.comment.user.login, '[bot]') &&
contains(github.event.comment.body, 'can help with this pull request')
# Comment deletion goes through the issues API, but GitHub gates the
# endpoint by where the comment lives: issue comments need `issues`,
# PR-conversation comments need `pull-requests`. The prefilter restricts
# this job to PR comments, so pull-requests is the one that matters;
# issues is kept in case the prefilter is ever widened.
permissions:
issues: write
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions and fires on attacker-postable events.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const comment = context.payload.comment
// Belt and suspenders on top of the job-level prefilter: only
// delete when the author is a real GitHub App bot AND the body
// matches the self-promotion shape ("... can help with this
// pull request. Just @<handle> ..."). A human quoting the ad
// text is not a Bot; a bot posting real work output doesn't
// match the promo shape.
const isBot = comment.user.type === "Bot"
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
if (!isBot || !isPromo) {
core.info("not an agent promo comment, leaving it alone")
return
}
await github.rest.issues.deleteComment({
...context.repo,
comment_id: comment.id,
})
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
@@ -1,65 +0,0 @@
# Cloud coding agents append promotional badge blocks to PR bodies after the
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
# marker comments. The agent itself never sees that content, so no repo rule or
# agent instruction can prevent it. This strips it from the PR description on
# open/edit, keeping only the agent-authored content between the markers.
#
# Uses pull_request_target so the token has write access on PRs from forks. That
# trigger is only unsafe when a job checks out and executes PR code — this one
# never checks out the repository, it only calls the REST API.
name: repo-strip-agent-badges
on:
pull_request_target:
types: [opened, edited]
concurrency:
group: strip-agent-badges-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
strip:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
permissions:
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions under pull_request_target.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
// Re-fetch instead of trusting the event payload: the body may have
// been edited again between the event firing and this run (agent
// harnesses edit PR bodies post-open), and updating from the stale
// snapshot would clobber the newer content.
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
})
const body = pr.body || ""
// The BEGIN/END comments wrap the agent-authored content; everything
// outside them (vendor promo badges, "open in <tool>" links) is
// appended by the harness. Keep only what's between the markers.
// The backreference requires BEGIN and END to name the same vendor.
// No markers -> no match -> body passes through unchanged.
const cleaned = body
.replace(
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
"$2",
)
.trimEnd()
// No change means a previous run already cleaned this body. Returning
// without an update is what stops `edited` from retriggering forever.
if (cleaned === body) {
core.info("nothing to strip")
return
}
await github.rest.pulls.update({
...context.repo,
pull_number: pr.number,
body: cleaned,
})
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
-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) || '' }}"
-145
View File
@@ -1,145 +0,0 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
-2
View File
@@ -42,8 +42,6 @@ event names. It exports:
"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**
## The Activation Funnel
The canonical funnel that downstream analytics depends on:
+2 -4
View File
@@ -51,8 +51,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "staging",
"CLINE_DIR": "${userHome}/.cline_staging"
"CLINE_ENVIRONMENT": "staging"
}
},
{
@@ -76,8 +75,7 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "local",
"CLINE_DIR": "${userHome}/.cline_local"
"CLINE_ENVIRONMENT": "local"
}
},
{
-32
View File
@@ -1,32 +0,0 @@
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
## Cloud Agent Instructions
### Cline CLI
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
### Build / Lint / test
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
### GUI display
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
### VS Code extension (`apps/vscode`, package `claude-dev`)
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
+1 -1
View File
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
```bash
cline "Run tests and fix any failures"
git diff origin/main | cline "Review these changes for issues"
git diff origin/main | cline "Review these changes for issues"
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
```
-46
View File
@@ -1,51 +1,5 @@
# Cline CLI Changelog
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
- Fixed provider config not reloading when switching models
- Fixed auto-update failing to detect Bun global installs after symlink resolution
- Fixed unexpected logouts caused by transient network or server errors during token refresh
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
- Session id is now preserved when continuing within the same session
- Hardened context compaction budget handling
## 3.0.39
- You can now select Cline free models on the ClinePass provider in the model picker
+1 -16
View File
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
@@ -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",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.46",
"version": "3.0.39",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -78,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.7",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.33.0",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
-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);
-494
View File
@@ -1,494 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
import {
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
stopAllConnectors,
} from "./connect";
const mocks = vi.hoisted(() => ({
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
persistConnectorConnection: vi.fn(),
removePersistedConnectorConnection: vi.fn(),
run: vi.fn(),
validate: vi.fn(),
}));
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
}));
vi.mock("../connectors/registry", () => ({
getConnector: mocks.getConnector,
listConnectors: mocks.listConnectors,
}));
describe("runConnectAdapter", () => {
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.validate.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
});
afterEach(() => {
if (previousDetachedChild === undefined) {
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
} else {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
}
});
it("persists a successful detached connector start", async () => {
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "token"],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists a successful env-only connector start", async () => {
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
[],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists connector-resolved launch arguments", async () => {
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("resolved_bot");
context.setPersistenceArgs([
"--bot-token",
"token",
"--bot-username",
"resolved_bot",
]);
return 0;
},
);
await expect(
runConnectAdapter("telegram", ["--bot-token", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"resolved_bot",
["--bot-token", "token", "--bot-username", "resolved_bot"],
);
});
it("does not rewrite persistence when a connector is already running", async () => {
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it.each([
"-i",
"--interactive",
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
await expect(
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
"telegram",
"cline_bot",
);
});
it("does not change persistence after a failed foreground run", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist a failed detached launch", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves persistence unchanged when an internal detached child exits", async () => {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist help invocations", async () => {
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves autostart unchanged during shared process cleanup", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(stopAllConnectors(io)).resolves.toEqual({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
executed: 1,
});
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("disables autostart for an explicit stop-all command", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(runStopAllConnectors(io)).resolves.toBe(0);
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
});
it("validates a replacement before stopping the active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.validate.mockResolvedValue(1);
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "bad-token"], io),
).resolves.toBe(1);
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
expect(stopInstance).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
it("shows restart help without stopping an active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
expect(mocks.validate).not.toHaveBeenCalled();
expect(stopInstance).not.toHaveBeenCalled();
});
it("restores the last successful launch when a replacement fails", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run
.mockResolvedValueOnce(1)
.mockImplementationOnce(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenNthCalledWith(
1,
["-k", "new-token"],
io,
expect.any(Object),
);
expect(mocks.run).toHaveBeenNthCalledWith(
2,
["-k", "old-token"],
io,
expect.any(Object),
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "old-token"],
);
});
it("restarts an active instance without persisted rollback arguments", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenCalledWith(
["-k", "new-token"],
io,
expect.any(Object),
);
});
it("does not start a replacement when the active process cannot be stopped", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).not.toHaveBeenCalled();
});
it("does not count an already-running instance as a successful replacement", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(mocks.run).toHaveBeenCalledTimes(1);
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] replacement was not started because telegram instance cline_bot is still running",
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
});
});
+15 -203
View File
@@ -1,29 +1,10 @@
import {
disableConnectorAutostart,
getPersistedConnectorConnection,
listActiveConnectors,
persistConnectorConnection,
removePersistedConnectorConnection,
} from "@cline/core";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import { getConnector, listConnectors } from "../connectors/registry";
import type {
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
export async function stopAllConnectors(
io: ConnectIo,
): Promise<ConnectStopResult & { executed: number }> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
let executed = 0;
for (const entry of listConnectors()) {
@@ -37,209 +18,42 @@ export async function stopAllConnectors(
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
return { stoppedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
const { stoppedProcesses, stoppedSessions, executed } =
await stopAllConnectors(io);
if (executed === 0) {
io.writeln("[connect] no adapters support stop yet");
return 0;
}
disableConnectorAutostart();
io.writeln(
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
);
return failedProcesses === 0 ? 0 : 1;
return 0;
}
export async function runStopConnector(
adapterName: string,
io: ConnectIo,
options: {
autostart: "disable" | "preserve";
instanceId?: string;
} = {
autostart: "disable",
},
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const stop = options.instanceId
? connector.stopInstance
? () => connector.stopInstance?.(options.instanceId ?? "", io)
: undefined
: connector.stopAll
? () => connector.stopAll?.(io)
: undefined;
if (!stop) {
if (!connector.stopAll) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
const result = await stop();
if (!result) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
if (options.autostart === "disable") {
disableConnectorAutostart(connector.name, options.instanceId);
}
const result: ConnectStopResult = await connector.stopAll(io);
io.writeln(
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
);
return result.failedProcesses === 0 ? 0 : 1;
}
export async function runRestartConnector(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
requestedInstanceId?: string,
): Promise<number> {
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const activeInstances = listActiveConnectors().filter(
(record) => record.type === adapterName,
);
if (!requestedInstanceId && activeInstances.length > 1) {
io.writeErr(
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
);
return 1;
}
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
const targetIsActive =
instanceId !== undefined &&
activeInstances.some((record) => record.instanceId === instanceId);
if (!targetIsActive || !instanceId) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
const previousConnection = getPersistedConnectorConnection(
adapterName,
instanceId,
);
const stopExitCode = await runStopConnector(adapterName, io, {
autostart: "preserve",
instanceId,
});
if (stopExitCode !== 0) {
return stopExitCode;
}
const replacement = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
if (replacement.exitCode === 0) {
if (replacement.instanceId && replacement.instanceId !== instanceId) {
removePersistedConnectorConnection(adapterName, instanceId);
}
return 0;
}
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
io.writeErr(
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
);
return 1;
}
if (!previousConnection) {
io.writeErr(
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
);
return replacement.exitCode;
}
io.writeErr(
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
);
const rollback = await runConnectAdapterWithResult(
adapterName,
previousConnection.lastSuccessfulArgs,
io,
);
if (rollback.exitCode === 0) {
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
} else {
io.writeErr(
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
);
}
return replacement.exitCode;
}
interface ConnectAdapterResult {
exitCode: number;
instanceId?: string;
}
async function runConnectAdapterWithResult(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<ConnectAdapterResult> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return { exitCode: 1 };
}
let persistenceArgs = passthroughArgs;
let persistenceInstanceId: string | undefined;
const context: ConnectRunContext = {
setPersistenceArgs: (args) => {
persistenceArgs = [...args];
},
setPersistenceInstanceId: (instanceId) => {
persistenceInstanceId = instanceId;
},
};
const exitCode = await connector.run(passthroughArgs, io, context);
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
return { exitCode, instanceId: persistenceInstanceId };
}
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
const isInteractiveInvocation = passthroughArgs.some((arg) =>
INTERACTIVE_FLAGS.has(arg),
);
const isDetachedChild =
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
isInteractiveInvocation
) {
disableConnectorAutostart(connector.name, persistenceInstanceId);
} else if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
persistenceInstanceId
) {
persistConnectorConnection(
connector.name,
persistenceInstanceId,
persistenceArgs,
);
}
return { exitCode, instanceId: persistenceInstanceId };
return 0;
}
export async function runConnectAdapter(
@@ -247,14 +61,12 @@ export async function runConnectAdapter(
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
return connector.run(passthroughArgs, io);
}
export function formatAdapterList(): string {
-41
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 {
@@ -22,7 +21,6 @@ const {
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
@@ -51,10 +49,8 @@ const {
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
mockEnsureFileExists: vi.fn(),
mockListActiveConnectors: vi.fn(() => []),
mockStopAllConnectors: vi.fn(async () => ({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
})),
@@ -73,7 +69,6 @@ vi.mock("@cline/core", () => ({
readHubDiscovery: mockReadHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
@@ -104,7 +99,6 @@ describe("runDoctorCommand", () => {
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
@@ -180,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);
@@ -288,7 +248,6 @@ describe("runDoctorCommand", () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
+5 -13
View File
@@ -4,7 +4,6 @@ import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
listActiveConnectors,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
@@ -12,15 +11,14 @@ import {
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
} from "@cline/shared";
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,
listActiveConnectors,
} from "../connectors/status";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
@@ -51,8 +49,6 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -341,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,
@@ -425,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,
}),
);
}),
-1
View File
@@ -136,7 +136,6 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
interactive: !!opts.tui,
outputMode: opts.json ? "json" : "text",
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
sandbox: !!opts.dataDir,
acpMode: !!opts.acp,
thinking: false,
+2 -4
View File
@@ -148,10 +148,8 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
return raw;
}
return undefined;
+3 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -10,7 +9,6 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -65,8 +63,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -98,7 +96,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: parseMode(opts.mode) ?? "yolo",
mode: opts.mode === "plan" ? "plan" : "act",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -40,7 +39,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
"openai/gpt-5.3-codex",
).trim();
return { provider, model };
}
@@ -166,10 +165,7 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
mode: parsed.mode === "plan" ? "plan" : "act",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -233,7 +229,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--mode <act|plan>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
+22 -39
View File
@@ -59,7 +59,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -938,18 +937,9 @@ class DiscordConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopDiscordConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
): Promise<number> {
if (!options.applicationId) {
@@ -970,16 +960,7 @@ class DiscordConnector extends ConnectorBase<
);
return 1;
}
return 0;
}
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.applicationId);
const statePath = this.resolveConnectorStatePath(options.applicationId);
const bindingsPath = this.resolveBindingsPath(options.applicationId);
const staleState = this.removeStaleState(
@@ -990,24 +971,26 @@ class DiscordConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Discord connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Discord connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
+52 -76
View File
@@ -55,7 +55,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -410,66 +409,11 @@ class GoogleChatConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopGoogleChatConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
private parseCredentials(
options: ConnectGoogleChatOptions,
):
| { client_email: string; private_key: string; project_id?: string }
| undefined {
if (!options.credentialsJson) {
return undefined;
}
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
return {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string" ? parsed.project_id : undefined,
};
}
protected override async validateOptions(
options: ConnectGoogleChatOptions,
io: ConnectIo,
): Promise<number> {
try {
this.parseCredentials(options);
return 0;
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
protected override async runWithOptions(
options: ConnectGoogleChatOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -480,25 +424,26 @@ class GoogleChatConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -507,7 +452,38 @@ class GoogleChatConnector extends ConnectorBase<
});
const logger = createChatSdkLogger(loggerAdapter);
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
const parsedCredentials = this.parseCredentials(options);
let parsedCredentials:
| { client_email: string; private_key: string; project_id?: string }
| undefined;
if (options.credentialsJson) {
try {
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
parsedCredentials = {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string"
? parsed.project_id
: undefined,
};
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
const endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
const gchat = createGoogleChatAdapter(
parsedCredentials
+19 -31
View File
@@ -51,7 +51,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import { getConnectorSystemPrompt } from "./prompts";
@@ -478,23 +477,11 @@ class LinearConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopLinearConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectLinearOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -505,24 +492,25 @@ class LinearConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<LinearThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
+21 -33
View File
@@ -61,7 +61,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -669,23 +668,11 @@ class SlackConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopSlackConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectSlackOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const stateStorePath = this.resolveStateStorePath(options.userName);
@@ -697,26 +684,27 @@ class SlackConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<SlackThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -2,19 +2,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __test__, telegramConnector } from "./telegram";
const mocks = vi.hoisted(() => ({
spawnDetachedConnector: vi.fn(),
}));
vi.mock("../common", async (importOriginal) => ({
...(await importOriginal<typeof import("../common")>()),
spawnDetachedConnector: mocks.spawnDetachedConnector,
}));
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
(
telegramConnector as unknown as {
@@ -25,11 +15,6 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
const originalClineDataDir = process.env.CLINE_DATA_DIR;
const tempDataDirs: string[] = [];
beforeEach(() => {
vi.clearAllMocks();
mocks.spawnDetachedConnector.mockReturnValue(42);
});
function useTempClineDataDir(): string {
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
tempDataDirs.push(dataDir);
@@ -168,7 +153,7 @@ describe("telegramConnector", () => {
expect(options.botUsername).toBe("test_bot");
});
it("validates a token before reporting its connector as already running", async () => {
it("does not call getMe when the token-only connector is already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
@@ -182,87 +167,26 @@ describe("telegramConnector", () => {
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () =>
Response.json({
ok: true,
result: { username: "resolved_bot" },
}),
);
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected getMe call");
});
vi.stubGlobal("fetch", fetchImpl);
const output: string[] = [];
const errors: string[] = [];
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
},
{
setPersistenceArgs: vi.fn(),
setPersistenceInstanceId: vi.fn(),
},
),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
}),
).resolves.toBe(0);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).not.toHaveBeenCalled();
expect(errors).toEqual([]);
expect(output).toEqual([
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
]);
});
it("reports the resolved bot username in persistence args", async () => {
const dataDir = useTempClineDataDir();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
}),
);
const setPersistenceArgs = vi.fn();
const setPersistenceInstanceId = vi.fn();
mocks.spawnDetachedConnector.mockImplementation(() => {
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
pid: process.pid,
}),
);
return process.pid;
});
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: () => {},
writeErr: () => {},
},
{ setPersistenceArgs, setPersistenceInstanceId },
),
).resolves.toBe(0);
expect(setPersistenceArgs).toHaveBeenCalledWith([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--bot-username",
"resolved_bot",
]);
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
});
});
describe("telegram bot username resolution", () => {
+22 -50
View File
@@ -20,7 +20,7 @@ import {
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
import { ConnectorBase } from "../base";
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
import { isProcessRunning } from "../common";
import {
type ActiveConnectorTurn,
handleConnectorUserTurn,
@@ -51,7 +51,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -605,37 +604,10 @@ class TelegramConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopTelegramConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
options: ConnectTelegramOptions,
io: ConnectIo,
): Promise<number> {
try {
await resolveTelegramBotUsername({
...options,
botUsername: undefined,
});
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
protected override async runWithOptions(
inputOptions: ConnectTelegramOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
if (
!inputOptions.botUsername &&
@@ -649,7 +621,7 @@ class TelegramConnector extends ConnectorBase<
io.writeln(
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
);
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
return 0;
}
}
let resolvedBotUsername: string;
@@ -666,8 +638,6 @@ class TelegramConnector extends ConnectorBase<
const backgroundArgs = inputOptions.botUsername
? rawArgs
: [...rawArgs, "--bot-username", resolvedBotUsername];
context.setPersistenceArgs(backgroundArgs);
context.setPersistenceInstanceId(options.botUsername);
const statePath = this.resolveConnectorStatePath(options.botUsername);
const bindingsPath = this.resolveBindingsPath(options.botUsername);
const staleState = this.removeStaleState(
@@ -678,24 +648,26 @@ class TelegramConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Telegram connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Telegram connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
+20 -31
View File
@@ -55,7 +55,6 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -445,27 +444,15 @@ class WhatsAppConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopWhatsAppConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectWhatsAppOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
const instanceKey = resolveInstanceKey({
phoneNumberId: options.phoneNumberId,
userName: options.userName,
});
context.setPersistenceInstanceId(instanceKey);
const statePath = this.resolveConnectorStatePath(instanceKey);
const bindingsPath = this.resolveBindingsPath(instanceKey);
const staleState = this.removeStaleState(
@@ -476,24 +463,26 @@ class WhatsAppConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
}
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage: "failed to launch WhatsApp connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch WhatsApp connector in background",
})
) {
return 0;
}
const loggerAdapter = createCliLoggerAdapter({
-208
View File
@@ -1,208 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectorBase } from "./base";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
import type { ConnectIo } from "./types";
const mocks = vi.hoisted(() => ({
isProcessRunning: vi.fn(),
spawnDetachedConnector: vi.fn(),
terminateProcess: vi.fn(),
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal<typeof import("./common")>()),
isProcessRunning: mocks.isProcessRunning,
spawnDetachedConnector: mocks.spawnDetachedConnector,
terminateProcess: mocks.terminateProcess,
}));
class TestConnector extends ConnectorBase<
Record<string, never>,
{ pid: number }
> {
constructor() {
super("test", "Test connector");
}
protected readOptions(): Record<string, never> {
return {};
}
protected async runWithOptions(): Promise<number> {
return 0;
}
runBackground(
io: ConnectIo,
options?: {
readState?: () => { pid: number } | undefined;
isRunning?: (state: { pid: number }) => boolean;
startupTimeoutMs?: number;
},
): Promise<number | undefined> {
return this.maybeRunInBackground({
rawArgs: ["--token", "secret"],
io,
interactive: false,
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
statePath: "/tmp/test-connector.json",
readState: options?.readState ?? (() => undefined),
isRunning: options?.isRunning ?? (() => false),
formatAlreadyRunningMessage: () => "already running",
formatBackgroundStartMessage: (pid) => `started ${pid}`,
foregroundHint: "foreground hint",
launchFailureMessage: "launch failed",
startupTimeoutMs: options?.startupTimeoutMs,
});
}
stopProcess(
io: ConnectIo,
options: {
statePath: string;
readState: (path: string) => { pid: number } | undefined;
stopSessions?: (state: { pid: number }) => Promise<number>;
clearBindings?: (state: { pid: number }) => void;
},
) {
return this.stopManagedProcess({
io,
statePath: options.statePath,
readState: options.readState,
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
getPid: (state) => state.pid,
stopSessions: options.stopSessions ?? (async () => 0),
clearBindings: options.clearBindings,
});
}
}
describe("ConnectorBase background launch", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.isProcessRunning.mockReturnValue(true);
mocks.terminateProcess.mockResolvedValue(true);
});
it("returns a failure exit code when the detached process is not created", async () => {
mocks.spawnDetachedConnector.mockReturnValue(0);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
});
it("returns success only after a detached process receives a pid", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
let reads = 0;
await expect(
new TestConnector().runBackground(io, {
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
isRunning: () => true,
}),
).resolves.toBe(0);
expect(io.writeln).toHaveBeenCalledWith("started 42");
});
it("fails when the detached child exits before becoming ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: child exited before becoming ready",
);
});
it("terminates a detached child that never becomes ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
await expect(
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
).resolves.toBe(1);
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: timed out after 0ms",
);
});
it("returns a distinct result when a connector is already running", async () => {
await expect(
new TestConnector().runBackground(io, {
readState: () => ({ pid: 99 }),
isRunning: () => true,
}),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(io.writeln).toHaveBeenCalledWith("already running");
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
});
it("keeps state and reports failure when the process survives termination", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(true);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
expect(removeStateFile).not.toHaveBeenCalled();
expect(stopSessions).not.toHaveBeenCalled();
expect(clearBindings).not.toHaveBeenCalled();
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] failed to stop connector process pid=42",
);
});
it("cleans stale state after confirming the process is already gone", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(false);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 1,
});
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
});
});
+11 -86
View File
@@ -3,7 +3,6 @@ import { join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
@@ -14,19 +13,15 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "./types";
const SHOW_HELP_ERROR = "__SHOW_HELP__";
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
const CONNECTOR_STARTUP_POLL_MS = 100;
export abstract class ConnectorBase<Options, State>
implements ConnectCommandDefinition
{
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
constructor(
public readonly name: string,
@@ -46,16 +41,8 @@ export abstract class ConnectorBase<Options, State>
options: Options,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
protected async validateOptions(
_options: Options,
_io: ConnectIo,
): Promise<number> {
return 0;
}
showHelp(io: ConnectIo): void {
const output = this.createCommand().helpInformation().trimEnd();
for (const line of output.split("\n")) {
@@ -63,11 +50,7 @@ export abstract class ConnectorBase<Options, State>
}
}
async run(
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
@@ -80,27 +63,7 @@ export abstract class ConnectorBase<Options, State>
io.writeErr(message);
return 1;
}
const validationExitCode = await this.validateOptions(options, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
return this.runWithOptions(options, rawArgs, io, context);
}
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === SHOW_HELP_ERROR) {
this.showHelp(io);
return 0;
}
io.writeErr(message);
return 1;
}
return await this.validateOptions(options, io);
return this.runWithOptions(options, rawArgs, io);
}
protected parseArgs(rawArgs: string[]): Options {
@@ -182,15 +145,14 @@ export abstract class ConnectorBase<Options, State>
formatBackgroundStartMessage: (pid: number) => string;
foregroundHint: string;
launchFailureMessage: string;
startupTimeoutMs?: number;
}): Promise<number | undefined> {
}): Promise<boolean> {
if (input.interactive || process.env[input.childEnvVar] === "1") {
return undefined;
return false;
}
const runningState = input.readState(input.statePath);
if (runningState && input.isRunning(runningState)) {
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
return true;
}
const pid = spawnDetachedConnector(
["connect", this.name],
@@ -199,32 +161,11 @@ export abstract class ConnectorBase<Options, State>
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
return 1;
return true;
}
input.io.writeln(input.formatBackgroundStartMessage(pid));
input.io.writeln(input.foregroundHint);
const startedAt = Date.now();
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
while (Date.now() - startedAt < timeoutMs) {
const state = input.readState(input.statePath);
if (state && input.isRunning(state)) {
return 0;
}
if (!isProcessRunning(pid)) {
input.io.writeErr(
`${input.launchFailureMessage}: child exited before becoming ready`,
);
return 1;
}
await new Promise((resolve) =>
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
);
}
await terminateProcess(pid);
input.io.writeErr(
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
);
return 1;
return true;
}
protected async stopAllFromStatePaths(
@@ -236,15 +177,13 @@ export abstract class ConnectorBase<Options, State>
) => Promise<ConnectStopResult>,
): Promise<ConnectStopResult> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
for (const statePath of statePaths) {
const result = await stopInstance(statePath, io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, failedProcesses, stoppedSessions };
return { stoppedProcesses, stoppedSessions };
}
protected async stopManagedProcess(input: {
@@ -259,31 +198,17 @@ export abstract class ConnectorBase<Options, State>
const state = input.readState(input.statePath);
if (!state) {
this.removeStateFile(input.statePath);
return {
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
};
return { stoppedProcesses: 0, stoppedSessions: 0 };
}
const pid = input.getPid(state);
let stoppedProcesses = 0;
if (await terminateProcess(pid)) {
if (await terminateProcess(input.getPid(state))) {
stoppedProcesses = 1;
input.io.writeln(input.describeStoppedProcess(state));
} else if (isProcessRunning(pid)) {
input.io.writeErr(
`[connect] failed to stop connector process pid=${pid}`,
);
return {
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
};
}
const stoppedSessions = await input.stopSessions(state);
input.clearBindings?.(state);
this.removeStateFile(input.statePath);
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
return { stoppedProcesses, stoppedSessions };
}
protected parseOptionalInteger(
-18
View File
@@ -83,24 +83,6 @@ describe("spawnDetachedConnector", () => {
],
});
});
it("marks detached children and removes the hub-daemon-only environment flag", () => {
const env = {
CLINE_BUILD_ENV: "production",
CLINE_RUN_AS_HUB_DAEMON: "1",
UNCHANGED: "value",
};
expect(
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
).toEqual({
CLINE_BUILD_ENV: "production",
CLINE_CONNECTOR_DETACHED_CHILD: "1",
CLINE_TELEGRAM_CONNECT_CHILD: "1",
UNCHANGED: "value",
});
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
});
});
describe("readSessionReplyText", () => {
+5 -29
View File
@@ -10,24 +10,11 @@ import {
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
} from "@cline/shared";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { createCliLoggerAdapter } from "../logging/adapter";
import { logSpawnedProcess } from "../logging/process";
import { resolveCliLaunchSpec } from "../utils/internal-launch";
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
"CLINE_CONNECTOR_DETACHED_CHILD";
/**
* Internal success from a detached connect when an instance is already running.
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
* state.
*/
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
return rawArgs.includes(flag);
}
@@ -136,19 +123,6 @@ function buildDetachedConnectorCommand(
};
}
function buildDetachedConnectorEnv(
childEnvKey: string,
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const childEnv = {
...withResolvedClineBuildEnv(env),
[childEnvKey]: "1",
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
};
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
return childEnv;
}
export function resolveConnectorDebugLogPath(
adapterName: string,
instanceKey: string,
@@ -216,7 +190,10 @@ export function spawnDetachedConnector(
detachedLogFd === undefined
? "ignore"
: ["ignore", detachedLogFd, detachedLogFd],
env: buildDetachedConnectorEnv(childEnvKey),
env: {
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
@@ -268,7 +245,6 @@ export function spawnDetachedConnector(
export const __test__ = {
buildDetachedConnectorArgs,
buildDetachedConnectorCommand,
buildDetachedConnectorEnv,
};
export function readJsonFile<T>(path: string, fallback: T): T {
@@ -67,62 +67,6 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("keeps streaming when tool status delivery fails", async () => {
let handlers: StreamHandlers | undefined;
const log = vi.fn();
const statusError = new Error("message_not_found");
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.tool_call_start",
payload: { toolName: "run_commands" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
handlers?.onEvent({
eventType: "runtime.chat.text_delta",
payload: { text: "Final response" },
});
return {
result: {
text: "Final response",
finishReason: "stop",
iterations: 1,
},
};
},
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onToolStatus: async () => {
throw statusError;
},
})) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("Final response");
expect(log).toHaveBeenCalledWith(
"Connector tool status delivery failed",
expect.objectContaining({
severity: "warn",
transport: "slack",
error: statusError,
}),
);
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
+1 -11
View File
@@ -169,17 +169,7 @@ export function createConnectorRuntimeTurnStream(input: {
return;
}
lastStatusMessage = message;
try {
await input.onToolStatus?.(message);
} catch (error) {
input.logger.core.log("Connector tool status delivery failed", {
severity: "warn",
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
error,
});
}
await input.onToolStatus?.(message);
};
const stopStreaming = input.client.streamEvents(
@@ -1,38 +1,86 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ActiveConnectorRecord } from "@cline/shared";
import { listConnectorCatalog } from "@cline/shared";
import { resolveConnectorDataDir } from "@cline/shared/storage";
import { resolveClineDataDir } from "@cline/core";
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export type ActiveConnectorRecord = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
};
function listConnectorStatePaths(
type: ActiveConnectorRecord["type"],
): string[] {
const dir = join(resolveClineDataDir(), "connectors", type);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
.map((name) => join(dir, name));
}
function readJsonRecord(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore malformed connector state.
}
return undefined;
}
type ConnectorFieldKey = keyof Omit<
ActiveConnectorRecord,
"id" | "type" | "instanceId" | "pid" | "hubUrl"
"id" | "type" | "pid" | "hubUrl"
>;
const connectorFieldExtractors: Record<
ConnectorFieldKey,
(record: Record<string, unknown>) => string | number | undefined
(p: Record<string, unknown>) => string | number | undefined
> = {
startedAt: (record) =>
typeof record.startedAt === "string" ? record.startedAt : undefined,
port: (record) => (typeof record.port === "number" ? record.port : undefined),
baseUrl: (record) =>
typeof record.baseUrl === "string" ? record.baseUrl : undefined,
connectionMode: (record) =>
typeof record.connectionMode === "string"
? record.connectionMode
: undefined,
userName: (record) =>
typeof record.userName === "string" ? record.userName : undefined,
botUsername: (record) =>
typeof record.botUsername === "string" ? record.botUsername : undefined,
applicationId: (record) =>
typeof record.applicationId === "string" ? record.applicationId : undefined,
phoneNumberId: (record) =>
typeof record.phoneNumberId === "string" ? record.phoneNumberId : undefined,
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
port: (p) => (typeof p.port === "number" ? p.port : undefined),
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
connectionMode: (p) =>
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
};
const connectorActiveStateConfigs: Record<
const connectorConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
@@ -56,33 +104,30 @@ const connectorActiveStateConfigs: Record<
},
};
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
function connectorRecordId(
type: ActiveConnectorRecord["type"],
fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
>,
pid: number,
): string {
const identity =
fields.botUsername ??
fields.userName ??
fields.applicationId ??
fields.phoneNumberId ??
String(pid);
return `${type}:${identity}`;
}
function readActiveConnectorRecord(
type: string,
type: ActiveConnectorRecord["type"],
statePath: string,
): ActiveConnectorRecord | undefined {
let parsed: Record<string, unknown>;
try {
const value = JSON.parse(readFileSync(statePath, "utf8")) as unknown;
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
parsed = value as Record<string, unknown>;
} catch {
const parsed = readJsonRecord(statePath);
if (!parsed) {
return undefined;
}
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
const hubUrl =
typeof parsed.hubUrl === "string"
@@ -93,8 +138,7 @@ function readActiveConnectorRecord(
if (!pid || !hubUrl || !isProcessRunning(pid)) {
return undefined;
}
const config = connectorActiveStateConfigs[type];
const config = connectorConfigs[type];
if (!config) {
return undefined;
}
@@ -114,22 +158,9 @@ function readActiveConnectorRecord(
(fields as Record<string, unknown>)[key] = value;
}
}
const instanceId =
type === "telegram"
? fields.botUsername
: type === "discord"
? fields.applicationId
: type === "whatsapp" && typeof parsed.instanceKey === "string"
? parsed.instanceKey
: fields.userName;
if (!instanceId) {
return undefined;
}
return {
id: `${type}:${instanceId}`,
id: connectorRecordId(type, fields, pid),
type,
instanceId,
pid,
hubUrl,
...fields,
@@ -137,23 +168,23 @@ function readActiveConnectorRecord(
}
export function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"discord",
"telegram",
"gchat",
"linear",
"slack",
"whatsapp",
];
const records: ActiveConnectorRecord[] = [];
for (const { name } of listConnectorCatalog()) {
const directory = join(resolveConnectorDataDir(), name);
if (!existsSync(directory)) {
continue;
}
for (const filename of readdirSync(directory)) {
if (!filename.endsWith(".json") || filename.endsWith(".threads.json")) {
continue;
}
const record = readActiveConnectorRecord(name, join(directory, filename));
for (const type of connectorTypes) {
for (const statePath of listConnectorStatePaths(type)) {
const record = readActiveConnectorRecord(type, statePath);
if (record) {
records.push(record);
}
}
}
return records.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
+1 -13
View File
@@ -5,25 +5,13 @@ export type ConnectIo = {
export type ConnectStopResult = {
stoppedProcesses: number;
failedProcesses: number;
stoppedSessions: number;
};
export type ConnectRunContext = {
setPersistenceArgs: (args: string[]) => void;
setPersistenceInstanceId: (instanceId: string) => void;
};
export interface ConnectCommandDefinition {
name: string;
description: string;
run(
args: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
validate(args: string[], io: ConnectIo): Promise<number>;
run(args: string[], io: ConnectIo): Promise<number>;
showHelp(io: ConnectIo): void;
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
}
+6 -20
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import {
disposeAll,
initVcr,
isHubDaemonProcess,
setConnectorCliLaunchSpec,
} from "@cline/shared";
import { disposeAll, initVcr, isHubDaemonProcess } from "@cline/shared";
import { logCliProcessError } from "./logging/errors";
import {
abortActiveRuntime,
cleanupActiveRuntime,
isAbortInProgress,
} from "./runtime/active-runtime";
import { resolveCliLaunchSpec } from "./utils/internal-launch";
import { writeErr } from "./utils/output";
// Initialize VCR before any HTTP requests are made.
@@ -22,20 +16,7 @@ initVcr(process.env.CLINE_VCR);
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (isHubDaemonProcess()) {
// The hub daemon owns its process-level abort handling. Installing the CLI's
// fatal rejection handler first would make expected abort rejections exit it.
void import("@cline/core/hub/daemon-entry");
} else {
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
if (cliLaunchSpec) {
setConnectorCliLaunchSpec({
launcher: cliLaunchSpec.launcher,
connectArgsPrefix: [...cliLaunchSpec.childArgsPrefix, "connect"],
cwd: process.cwd(),
});
}
let shuttingDown = false;
let handlingFatalProcessError = false;
const forwardSignalToRuntime = () => {
@@ -76,6 +57,11 @@ if (!isMainThread) {
});
void (async () => {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
let exitCode = 0;
try {
const { runCli } = await import("./main");
+4 -257
View File
@@ -1,6 +1,4 @@
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
@@ -20,7 +18,6 @@ vi.mock("node:fs", async () => {
const originalArgv = [...process.argv];
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const mockState = vi.hoisted(() => ({
runAgentImports: 0,
runInteractiveImports: 0,
@@ -64,13 +61,6 @@ const kanbanMocks = vi.hoisted(() => ({
const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const connectMocks = vi.hoisted(() => ({
formatAdapterList: vi.fn(() => ""),
runConnectAdapter: vi.fn(async () => 0),
runRestartConnector: vi.fn(async () => 0),
runStopAllConnectors: vi.fn(async () => 0),
runStopConnector: vi.fn(async () => 0),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<
(
@@ -208,7 +198,6 @@ vi.mock("./runtime/prompt", () => ({
}));
vi.mock("./commands/kanban", () => kanbanMocks);
vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./commands/connect", () => connectMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
@@ -219,17 +208,8 @@ vi.mock("./utils/telemetry", () => telemetryMocks);
vi.mock("./utils/worktree", () => worktreeMocks);
describe("runCli lightweight command dispatch", () => {
let globalSettingsRoot: string | undefined;
beforeEach(() => {
process.exitCode = undefined;
// Startup now reads persisted general settings; point the resolver at a
// fresh temp file so the developer's real settings cannot leak in.
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
globalSettingsRoot,
"global-settings.json",
);
mockState.runAgentImports = 0;
mockState.runInteractiveImports = 0;
mockState.runAgentCalls = 0;
@@ -293,16 +273,6 @@ describe("runCli lightweight command dispatch", () => {
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
connectMocks.formatAdapterList.mockReset();
connectMocks.formatAdapterList.mockReturnValue("");
connectMocks.runConnectAdapter.mockReset();
connectMocks.runConnectAdapter.mockResolvedValue(0);
connectMocks.runRestartConnector.mockReset();
connectMocks.runRestartConnector.mockResolvedValue(0);
connectMocks.runStopAllConnectors.mockReset();
connectMocks.runStopAllConnectors.mockResolvedValue(0);
connectMocks.runStopConnector.mockReset();
connectMocks.runStopConnector.mockResolvedValue(0);
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
@@ -329,16 +299,6 @@ describe("runCli lightweight command dispatch", () => {
afterEach(() => {
process.exitCode = undefined;
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (globalSettingsRoot) {
rmSync(globalSettingsRoot, { recursive: true, force: true });
globalSettingsRoot = undefined;
}
process.argv = [...originalArgv];
Object.defineProperty(process.stdin, "isTTY", {
value: originalStdinIsTTY,
@@ -373,55 +333,6 @@ describe("runCli lightweight command dispatch", () => {
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
}, 30_000);
it("routes connector restart arguments through the restart lifecycle", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
undefined,
);
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
});
it("routes a targeted connector restart to one instance", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart-instance",
"cline_bot",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
"cline_bot",
);
});
it("does not load runtime modules for root update", async () => {
@@ -937,172 +848,6 @@ describe("runCli lightweight command dispatch", () => {
);
});
describe("persisted general settings at startup", () => {
function writePersistedSettings(settings: Record<string, unknown>) {
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
if (!path) {
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
}
writeFileSync(path, JSON.stringify(settings));
}
it("restores the persisted plan mode when no mode flag is provided", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
);
});
it("prefers an explicit --act flag over the persisted plan mode", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts", "--act"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
);
});
it("restores the persisted auto-approve setting as a runtime policy", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: false },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
toolPolicies: {
"*": { autoApprove: true },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores disabled compaction across restarts", async () => {
writePersistedSettings({
compactionEnabled: false,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: false },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores the persisted compaction strategy across restarts", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --compaction flag over the persisted mode", async () => {
writePersistedSettings({ compactionEnabled: false });
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "agentic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("applies persisted settings to single-prompt runs as well", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
planActMode: "plan",
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
mode: "plan",
}),
expect.anything(),
);
});
});
it("forces chat view when resuming a session", async () => {
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
@@ -1557,6 +1302,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1643,7 +1389,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("uses Core's agentic compaction default for prompt runs", async () => {
it("enables truncation compaction by default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1658,6 +1404,7 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+17 -62
View File
@@ -43,11 +43,6 @@ import {
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import {
resolveStartupCompactionMode,
resolveStartupMode,
resolveStartupToolAutoApprove,
} from "./utils/startup-settings";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -367,11 +362,6 @@ export async function runCli(): Promise<void> {
.description("Connect to an external channel")
.argument("[channel]", "Channel to connect Cline CLI to")
.option("--stop", "Kill all current channel connections")
.option("--restart", "Restart a channel connection")
.option(
"--restart-instance <id>",
"Restart one connector instance (used by daemon recovery)",
)
.allowUnknownOption()
.passThroughOptions()
.addHelpText(
@@ -382,32 +372,16 @@ export async function runCli(): Promise<void> {
const {
formatAdapterList,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
runStopConnector,
} = await import("./commands/connect");
const opts = connectCmd.opts();
if (opts.stop && (opts.restart || opts.restartInstance)) {
io.writeErr("connect accepts only one of --stop or --restart");
ctx.exitCode = 1;
} else if (opts.stop) {
if (opts.stop) {
if (adapter) {
ctx.exitCode = await runStopConnector(adapter, io);
} else {
ctx.exitCode = await runStopAllConnectors(io);
}
} else if (opts.restart || opts.restartInstance) {
if (!adapter) {
io.writeErr("connect --restart requires a channel");
ctx.exitCode = 1;
} else {
ctx.exitCode = await runRestartConnector(
adapter,
connectCmd.args.slice(1),
io,
opts.restartInstance,
);
}
} else if (adapter) {
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
// connector-specific flags (everything after the adapter name).
@@ -870,6 +844,14 @@ export async function runCli(): Promise<void> {
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
args.autoApproveOverride ?? defaultToolAutoApprove;
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
writeErr(
@@ -944,28 +926,7 @@ export async function runCli(): Promise<void> {
coreServer: { createUserInstructionConfigService },
resolveSystemPrompt,
runAgent,
} = await loadCliRuntimeModules();
// General settings toggled in the TUI /settings panel persist to the
// global settings file; explicit CLI flags take precedence over the
// persisted values, which in turn override the built-in defaults.
const persistedGlobalSettings = coreServer.readGlobalSettings();
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove = resolveStartupToolAutoApprove(
args,
persistedGlobalSettings,
defaultToolAutoApprove,
);
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
const effectiveMode = resolveStartupMode(args, persistedGlobalSettings);
const effectiveCompactionMode = resolveStartupCompactionMode(
args,
persistedGlobalSettings,
);
} = await loadCliRuntimeModules();
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
@@ -1021,15 +982,9 @@ export async function runCli(): Promise<void> {
// 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 savedAccountId = selectedProviderSettings?.auth?.accountId;
if (savedAccountId) {
identifyTelemetryAccount({ id: savedAccountId, provider: "cline" });
}
}
@@ -1110,7 +1065,7 @@ export async function runCli(): Promise<void> {
interactive: args.interactive === true,
hasPrompt: !!args.prompt?.trim(),
cwd,
});
});
const config: Config = {
providerId: provider,
@@ -1125,13 +1080,13 @@ export async function runCli(): Promise<void> {
cwd,
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: effectiveMode,
mode: args.mode ?? "act",
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
},
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
compaction: buildCliCompactionConfig(effectiveCompactionMode),
compaction: buildCliCompactionConfig(args.compactionMode),
timeoutSeconds: args.timeoutSeconds,
sandbox: sandboxEnabled,
sandboxDataDir,
@@ -1139,7 +1094,7 @@ export async function runCli(): Promise<void> {
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
outputMode: args.outputMode,
mode: effectiveMode,
mode: args.mode,
logger: loggerAdapter.core,
loggerConfig: loggerAdapter.runtimeConfig,
telemetry: getCliTelemetryService(loggerAdapter.core),
+8 -5
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
let savedRejectionListeners: Function[] | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Function[];
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,7 +68,10 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on("unhandledRejection", listener);
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
}
savedRejectionListeners = undefined;
}
@@ -12,25 +12,6 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -65,7 +46,6 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -126,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 = {
@@ -150,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),
@@ -158,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 = {
@@ -183,15 +163,6 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -218,17 +189,6 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+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;
@@ -641,22 +641,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[]> => {
+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,
+48 -38
View File
@@ -55,44 +55,54 @@ const CLI_CLINE_PASS_LIMIT_MESSAGE = [
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";
vi.mock(
"@cline/core",
async (importActual: () => Promise<typeof import("@cline/core")>) => ({
...(await importActual()),
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
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",
},
}),
);
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.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",
},
}));
vi.mock("../utils/approval", () => ({
askQuestionInTerminal: vi.fn(),
+3 -7
View File
@@ -205,9 +205,7 @@ export async function runAgent(
event.error.message.trim()
) {
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message, {
modelId: config.modelId,
}).trim(),
formatCliErrorMessage(event.error.message).trim(),
);
}
handleEvent(event, config);
@@ -392,9 +390,7 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = formatCliErrorMessage(result.text, {
modelId: config.modelId,
}).trim();
const errorText = formatCliErrorMessage(result.text).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -415,7 +411,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = formatCliErrorMessage(err, { modelId: config.modelId });
const message = formatCliErrorMessage(err);
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
-8
View File
@@ -2,9 +2,6 @@ import {
getCurrentContextSize,
type ProviderSettings,
ProviderSettingsManager,
setCompactionModeGlobally,
setPlanActModeGlobally,
setToolAutoApproveGlobally,
type UserInstructionConfigService,
} from "@cline/core";
import { formatModeSwitchNotice } from "@cline/shared";
@@ -715,20 +712,15 @@ export async function runInteractive(
onTurnErrorReported: () => {},
onAutoApproveChange: (enabled) => {
setInteractiveAutoApprove(enabled);
setToolAutoApproveGlobally(enabled);
void refreshInteractiveSessionPolicies();
},
onCompactionModeChange: async (mode) => {
await sessionRuntime.ensureReady();
applyCliCompactionMode(config, mode);
setCompactionModeGlobally(mode);
await sessionRuntime.restartWithCurrentMessages();
},
onModeChange: async (mode) => {
if (!isInteractiveMode(mode)) return;
// Persist the user's choice immediately, even when the switch is
// deferred until the current turn aborts, so it survives restarts.
setPlanActModeGlobally(mode);
if (isRunning) {
pendingModeChange.current = mode;
pendingModeChange.source = "ui";
+2 -117
View File
@@ -17,19 +17,14 @@
// - Auto-approve all (Shift+Tab)
// ---------------------------------------------------------------------------
import { expect, test } from "@microsoft/tui-test";
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { test } from "@microsoft/tui-test";
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js";
import { clineEnv } from "../helpers/env.js";
import {
toggleAutoApproveAll,
waitForChatReady,
} from "../helpers/page-objects/chat.js";
import {
expectNotVisible,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
import { expectVisible } from "../helpers/terminal.js";
test.describe("cline (authenticated) - shows chat view", () => {
test.use({
@@ -58,113 +53,3 @@ test.describe("Auto-approve all - Shift+Tab toggle", () => {
await toggleAutoApproveAll(terminal);
});
});
test.describe("Dialog dismissal - panel is fully removed", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
...TERMINAL_WIDE,
env: clineEnv("default"),
});
type Background = {
mode: number | undefined;
color: number | undefined;
};
type TerminalSnapshot = ReturnType<Terminal["serialize"]> & {
baseY: number;
};
const backgroundsEqual = (
left: Background | undefined,
right: Background | undefined,
): boolean => left?.mode === right?.mode && left?.color === right?.color;
const snapshotTerminal = (terminal: Terminal): TerminalSnapshot => ({
...terminal.serialize(),
baseY: terminal.getCursor().baseY,
});
const findTextPosition = (
terminal: Terminal,
text: string,
): { x: number; y: number } => {
const lines = terminal.getViewableBuffer();
for (let y = 0; y < lines.length; y++) {
const x = lines[y].join("").indexOf(text);
if (x !== -1) {
return { x, y };
}
}
throw new Error(`Unable to locate visible text: ${text}`);
};
const getCellBackground = (
snapshot: TerminalSnapshot,
position: { x: number; y: number },
): Background => {
const targetRow = snapshot.baseY + position.y;
let background: Background = { mode: undefined, color: undefined };
for (let y = snapshot.baseY; y <= targetRow; y++) {
for (let x = 0; x < TERMINAL_WIDE.columns; x++) {
const shift = snapshot.shifts.get(`${x},${y}`);
if (shift?.bgColorMode !== undefined) {
background = { mode: shift.bgColorMode, color: shift.bgColor };
}
if (x === position.x && y === targetRow) {
return background;
}
}
}
throw new Error(
`Cell is outside the visible terminal: ${position.x},${position.y}`,
);
};
// @opentui-ui/dialog is built against @opentui/core ^0.1.69, whose
// Renderable.remove(id) took an id. Core 0.4.x renamed it to
// remove(child) and throws on a non-renderable argument, so the
// package's removeDialog() aborted before detaching its panel — the React
// portal content unmounted, but the imperative grey box stayed on screen
// over the chat. Asserting on the panel's background (not its text) is what
// distinguishes a leaked box from a clean teardown.
test("closing the help dialog removes its grey panel", async ({
terminal,
}) => {
await waitForChatReady(terminal);
const terminalBeforeDialog = snapshotTerminal(terminal);
await typeAndSubmit(terminal, "/help");
await expectVisible(terminal, "Keyboard Shortcuts");
const dialogPosition = findTextPosition(terminal, "Keyboard Shortcuts");
const backgroundAtDialogPosition = getCellBackground(
terminalBeforeDialog,
dialogPosition,
);
const dialogBackground = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
expect(dialogBackground).not.toEqual(backgroundAtDialogPosition);
terminal.keyEscape();
await expectNotVisible(terminal, "Keyboard Shortcuts");
// The panel unmounts a frame after its content. Poll the title's former
// position until the background captured from the visible panel is gone.
const deadline = Date.now() + 10_000;
let backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
while (
!backgroundsEqual(backgroundAfterDialog, backgroundAtDialogPosition) &&
Date.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 100));
backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
}
expect(backgroundAfterDialog).toEqual(backgroundAtDialogPosition);
});
});
-59
View File
@@ -309,62 +309,3 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+2 -42
View File
@@ -51,16 +51,9 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
);
}
@@ -158,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;
@@ -222,7 +183,6 @@ export async function loadClineAccountSnapshot(input: {
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
return {
user,
+10 -121
View File
@@ -1,7 +1,4 @@
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
} from "@cline/core";
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
@@ -11,8 +8,6 @@ import {
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -30,11 +25,9 @@ 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 {
buildReadFilesKeys,
parseApplyPatchInput,
parseAskQuestionInput,
parseEditorInput,
@@ -135,13 +128,12 @@ function formatToolParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) return fallback;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const sl = f.startLine != null ? String(f.startLine) : "undefined";
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={keys[i]}>
<span key={f.path}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -429,38 +421,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;
@@ -485,6 +445,14 @@ function ClinePassLimitErrorView(props: {
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 " />
@@ -501,71 +469,6 @@ function ClinePassLimitErrorView(props: {
);
}
function ClineFreeModelLimitErrorView(props: {
message: string;
defaultFg?: string;
}) {
const resetTime = extractClineFreeModelLimitResetTime(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">Daily free model limit reached</text>
<text
fg={props.defaultFg}
selectable
content="You've reached today's free usage limit for this model."
/>
<text
fg={props.defaultFg}
selectable
content={
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model."
}
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">Free model promotion ended</text>
<text
fg={props.defaultFg}
selectable
content="The free promotion for this model has ended and it is no longer available."
/>
<text
fg={props.defaultFg}
selectable
content="Select another model to continue."
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -690,17 +593,6 @@ export function ChatEntryView(props: {
/>
);
}
if (isClineFreeModelLimitErrorMessage(entry.text)) {
return (
<ClineFreeModelLimitErrorView
defaultFg={defaultFg}
message={entry.text}
/>
);
}
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -724,9 +616,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>
<em>Esc to cancel</em>
</text>
</box>
);
@@ -4,7 +4,6 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { palette } from "../../palette";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseEditorInput,
parseReadFilesInput,
@@ -23,14 +22,13 @@ export function formatApprovalParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) break;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const range =
f.startLine != null
? ` lines ${f.startLine}-${f.endLine ?? "end"}`
: "";
return (
<text key={keys[i]} fg="gray" selectable>
<text key={f.path} fg="gray" selectable>
{" "}
{shortenPath(f.path, 60)}
{range && <span fg="gray">{range}</span>}
@@ -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;
+4 -91
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;
@@ -30,11 +29,9 @@ interface AgentEventDeps {
}) => void;
onTurnErrorReported: TuiProps["onTurnErrorReported"];
verbose: boolean;
modelId?: string;
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
const openCompactionEntryRef = useRef(false);
const {
appendEntry,
updateLastEntry,
@@ -46,50 +43,8 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
} = 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;
@@ -129,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);
@@ -212,60 +165,24 @@ 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) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error, { modelId }),
text: formatCliErrorMessage(event.error),
});
}
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 });
}
@@ -283,7 +200,6 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
[
appendEntry,
updateLastEntry,
updateEntry,
closeInlineStream,
activeInlineStreamRef,
setIsRunning,
@@ -291,10 +207,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
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,
],
);
+3 -92
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,9 +21,7 @@ import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
ProviderConfigInputContent,
ProviderPickerContent,
UseExistingOrReconfigureContent,
@@ -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>) => (
@@ -38,7 +38,6 @@ export function usePromptInputController(input: {
onSubmit: TuiProps["onSubmit"];
initialPrompt?: string;
providerId: string;
modelId?: string;
configVerbose: boolean;
refreshRepoStatus: () => void;
setAppView: (view: AppView) => void;
@@ -51,7 +50,6 @@ export function usePromptInputController(input: {
onSubmit,
initialPrompt,
providerId,
modelId,
configVerbose,
refreshRepoStatus,
setAppView,
@@ -379,7 +377,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: formatCliErrorMessage(error, { modelId }),
text: formatCliErrorMessage(error),
});
}
} finally {
@@ -395,7 +393,6 @@ export function usePromptInputController(input: {
clearPasteAttachments,
configVerbose,
inputHistory,
modelId,
onSubmit,
providerId,
refreshRepoStatus,
@@ -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 -4
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 () => {
@@ -724,7 +731,6 @@ function App(props: TuiProps) {
addUsageDelta: session.addUsageDelta,
onTurnErrorReported: props.onTurnErrorReported,
verbose: props.config.verbose ?? false,
modelId: props.config.modelId,
});
const promptInput = usePromptInputController({
@@ -734,7 +740,6 @@ function App(props: TuiProps) {
onSubmit: props.onSubmit,
initialPrompt: props.initialPrompt,
providerId: props.config.providerId,
modelId: props.config.modelId,
configVerbose: props.config.verbose ?? false,
refreshRepoStatus,
setAppView,
+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,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
const info = parseReadFilesInput({
files: [{ path: "/a/SKILL.md" }, { path: "/a/SKILL.md" }],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(keys.length);
});
it("produces unique keys for duplicate paths from the file_paths shape", () => {
const info = parseReadFilesInput({
file_paths: ["/a/SKILL.md", "/a/SKILL.md", "/b/SKILL.md"],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(3);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps distinct paths in unique keys", () => {
const keys = buildReadFilesKeys([{ path: "/a.ts" }, { path: "/b.ts" }]);
expect(new Set(keys).size).toBe(2);
});
it("returns no keys for an empty list", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
-6
View File
@@ -91,12 +91,6 @@ export function parseReadFilesInput(input: unknown): ReadFilesInfo | undefined {
return undefined;
}
// A read_files call can list the same path more than once, so the raw path is
// not a unique React key. Prefix the array index to keep keys unique per row.
export function buildReadFilesKeys(files: { path: string }[]): string[] {
return files.map((f, i) => `${i}:${f.path}`);
}
export interface RunCommandsInfo {
commands: string[];
}
-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,14 +1,11 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClineFreeModelLimitMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -51,9 +48,6 @@ describe("cline-pass-errors", () => {
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"deepseek-v4-flash",
);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
@@ -73,44 +67,4 @@ describe("cline-pass-errors", () => {
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
it("recognizes and formats daily free model limits without usage-billing guidance", () => {
const raw =
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
expect(isClineFreeModelLimitErrorMessage(raw)).toBe(true);
expect(isClineFreeModelLimitErrorMessage(new Error(raw))).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClineFreeModelLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain("Error 429");
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Try again in 23h 59m",
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"select another model",
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"usage-based billing",
);
expect(
isClineFreeModelLimitErrorMessage(getCliClineFreeModelLimitMessage(raw)),
).toBe(true);
});
it("formats model-not-found errors for removed free models", () => {
const raw = new Error("Error 404: model not found");
expect(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
).toContain("Free model promotion ended");
expect(
isClineFreePromotionEndedErrorMessage(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
),
).toBe(true);
expect(
formatCliErrorMessage(raw, { modelId: "vendor/retired-model" }),
).toBe(raw.message);
});
});
+1 -86
View File
@@ -1,11 +1,7 @@
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineFreeModelLimitError,
isClineFreeModelLimitMessage,
isClineModelNotFoundMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
@@ -43,31 +39,6 @@ export function getCliClinePassLimitMessage(message: string): string {
return lines.filter((line) => line.trim().length > 0).join("\n");
}
const CLINE_FREE_MODEL_PREFIX = "cline-free/";
const CLINE_FREE_PROMOTION_ENDED_HEADER = "Free model promotion ended";
const CLINE_FREE_MODEL_LIMIT_HEADER = "Daily free model limit reached";
export function getCliClineFreePromotionEndedMessage(): string {
return [
CLINE_FREE_PROMOTION_ENDED_HEADER,
"The free promotion for this model has ended and it is no longer available.",
"Select another model to continue.",
"Open the model selector with /model.",
].join("\n");
}
export function getCliClineFreeModelLimitMessage(message: string): string {
const resetTime = extractClineFreeModelLimitResetTime(message);
return [
CLINE_FREE_MODEL_LIMIT_HEADER,
"You've reached today's free usage limit for this model.",
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model.",
"Open the model selector with /model.",
].join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -143,55 +114,7 @@ export function isClinePassLimitErrorMessage(error: unknown): boolean {
return typeof error === "string" && isClinePassLimitMessage(error);
}
// Detects that a deleted free model was requested: the backend answers "model
// not found" once a free promotion ends and the cline-free/ model is removed.
// The modelId gate keeps regular model-not-found errors on their generic path.
export function isClineFreePromotionEndedErrorMessage(
error: unknown,
modelId?: string,
): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
if (
message
.toLowerCase()
.includes(CLINE_FREE_PROMOTION_ENDED_HEADER.toLowerCase())
) {
return true;
}
if (!modelId?.startsWith(CLINE_FREE_MODEL_PREFIX)) {
return false;
}
return isClineModelNotFoundMessage(message);
}
export function isClineFreeModelLimitErrorMessage(error: unknown): boolean {
if (isClineFreeModelLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineFreeModelLimitError" ||
isClineFreeModelLimitMessage(error.message)
);
}
return (
typeof error === "string" &&
(error
.toLowerCase()
.includes(CLINE_FREE_MODEL_LIMIT_HEADER.toLowerCase()) ||
isClineFreeModelLimitMessage(error))
);
}
export function formatCliErrorMessage(
error: unknown,
options?: { modelId?: string },
): string {
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
@@ -203,14 +126,6 @@ export function formatCliErrorMessage(
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreeModelLimitErrorMessage(error)) {
return getCliClineFreeModelLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreePromotionEndedErrorMessage(error, options?.modelId)) {
return getCliClineFreePromotionEndedMessage();
}
if (error instanceof Error) {
return error.message;
}
+9 -6
View File
@@ -15,29 +15,31 @@ function createConfig(compaction?: Config["compaction"]): Config {
}
describe("CLI compaction mode helpers", () => {
it("defaults enabled compaction to agentic summarization", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
it("defaults enabled compaction to basic truncation", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
expect(getCliCompactionMode(createConfig())).toBe(
DEFAULT_CLI_COMPACTION_MODE,
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
"Truncation",
);
});
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");
});
@@ -45,6 +47,7 @@ describe("CLI compaction mode helpers", () => {
it("builds default and explicit core compaction config", () => {
expect(buildCliCompactionConfig()).toEqual({
enabled: true,
strategy: "basic",
});
expect(buildCliCompactionConfig("agentic")).toEqual({
enabled: true,
+6 -7
View File
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
CliCompactionMode,
"agentic" | "basic"
> = "agentic";
> = "basic";
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
agentic: "agentic",
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
} as const satisfies Record<CliCompactionMode, string>;
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
"Context compaction mode: agentic|basic|off (default: agentic)";
"Context compaction mode: agentic|basic|off (default: basic)";
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
@@ -31,11 +31,8 @@ export function parseCliCompactionMode(
}
export function buildCliCompactionConfig(
mode?: CliCompactionMode,
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
): NonNullable<Config["compaction"]> {
if (mode === undefined) {
return { enabled: true };
}
if (mode === "off") {
return { enabled: false };
}
@@ -46,7 +43,9 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
if (config.compaction?.enabled === false) {
return "off";
}
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
return config.compaction?.strategy === "agentic"
? "agentic"
: DEFAULT_CLI_COMPACTION_MODE;
}
export function applyCliCompactionMode(
+1 -6
View File
@@ -141,18 +141,13 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath,
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
-31
View File
@@ -222,37 +222,6 @@ describe("handleEvent text formatting", () => {
expect(errorOutput).toContain("--provider cline");
});
it("formats daily free model limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Daily free model limit reached");
expect(errorOutput).toContain("select another model");
expect(errorOutput).not.toContain("usage-based billing");
});
it("formats removed free model errors using the configured model id", () => {
handleEvent(
{
type: "error",
error: new Error("Error 404: model not found"),
recoverable: false,
} as unknown as AgentEvent,
{ modelId: "cline-free/retired-model" } as Config,
);
expect(errorOutput).toContain("Free model promotion ended");
expect(errorOutput).toContain("Select another model");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+1 -25
View File
@@ -1,8 +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 {
@@ -28,24 +24,6 @@ const TEAM_RUN_ACTIVE_SUFFIX = `${c.dim} ...${c.reset}`;
export function resolveStatusNoticeLabel(
event: AgentEvent,
): string | undefined {
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;
@@ -204,9 +182,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(
formatCliErrorMessage(event.error, { modelId: config.modelId }),
);
writeErr(formatCliErrorMessage(event.error));
}
break;
case "notice":

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